Preventing SMS OTP Toll Fraud with Layered Rate Limits and Geographic Controls
TL;DR: To prevent SMS OTP abuse and toll fraud, treat each request as a security-sensitive spend authorization, not as a message-send button. Admit it only after layered limits on account, destination, source, device, ne
TL;DR: To prevent SMS OTP abuse and toll fraud, treat each request as a security-sensitive spend authorization, not as a message-send button. Admit it only after layered limits on account, destination, source, device, network, and country; keep a deny-by-default destination policy; make every decision auditable; and fail closed for new OTP challenges when the risk-control path is unavailable. Keep transactional traffic, such as a fintech order receipt sent after payment settles, in a separate queue and policy domain so an OTP attack cannot consume its capacity or erase its compliance trail.
A single per-IP limit is weak protection. Attackers can distribute requests across addresses, rotate destination numbers, and concentrate traffic on routes with favorable economics. The defensible design correlates several imperfect signals, applies hard budget ceilings before the SMS gateway, and preserves evidence that explains why each challenge was allowed or denied.
How can you prevent SMS OTP abuse and toll fraud?
An unauthenticated OTP endpoint converts cheap HTTP requests into chargeable downstream work. If the caller can choose the destination, the endpoint also gives the caller influence over geography and routing. A bot does not need to complete a login to create cost or operational noise; it only needs the application to keep asking a carrier network to deliver messages.
The obvious signal is a jump in send attempts. The useful signals are more specific: attempts per normalized phone number, account, source IP or prefix, device cookie, autonomous system, country, and time window; the ratio of OTP verifications to sends; distinct destinations per source; retry spacing; and message volume per route. None proves abuse alone. Together they reveal fan-out, destination rotation, and a collapse in conversion that an aggregate request graph can hide. Consider 500 attempts spread across 250 source addresses: an IP-only threshold of 20 sees nothing remarkable, while a route budget and a falling verification ratio expose the shared downstream effect. The exact alert threshold still has to come from the service's baseline, because inventing one would turn capacity planning into theater.
This is correlation, not certainty.
Normalize first. Store phone numbers in E.164 form after parsing them with maintained numbering metadata, but do not mistake the country calling code for verified user location. A roaming user, a ported number, and an IP geolocation result can disagree for legitimate reasons. Geo controls therefore need an explicit policy and a review path, not a claim that geography is identity.
Capacity planning starts at the abuse ceiling rather than the expected login peak. If a service can admit 100 OTP sends per second, its worst permitted downstream exposure is 360,000 sends in an hour unless another bound trips sooner. That is too large a blast radius for many systems even when 100 requests per second is ordinary application capacity. Set independent hourly and daily route budgets, then decide how much of each budget can be consumed by new accounts, untrusted devices, and retries. Short-window throttles protect machinery; long-window budgets protect the business.
The receipt path deserves its own SLO. A settled-payment receipt is evidence of a completed transaction, while an OTP is an attempt to begin an authenticated session. Sharing a queue, retry pool, or country budget lets hostile pre-authentication traffic delay post-settlement communication. Separate them even if they eventually use the same carrier connection.
Put controls before the message queue
The safe sequence is parse, correlate, decide, reserve budget, enqueue, and then send. Reserving quota after enqueueing is accounting after the fact: workers can race, queues can grow, and retries can multiply the accepted work. A strongly consistent counter is not mandatory for every signal, but the final hard ceiling needs atomic semantics across all API instances.
Use several windows because each catches a different shape. A short token bucket absorbs a mistyped resend without allowing a burst loop. Sliding or fixed windows bound sustained activity by destination, account, source, and route. A daily budget limits total exposure even when the attacker stays just below every minute-level threshold. Avoid publishing exact thresholds to clients; return a generic response and record the precise internal reason.
Country policy should also execute before enqueue. Start from the countries the service has a documented reason to support, based on the user population, legal review, carrier registration, and operational coverage. Adding a country is a controlled change with an owner and review date. An allowlist is a blast-radius control, not sufficient authentication and not a substitute for monitoring. Its limitations are concrete: it can block legitimate travelers, it cannot distinguish a malicious request for an allowed-country number, and IP geolocation can disagree with the destination. A service with a genuinely global, unpredictable user population may find a static country allowlist unsuitable; it should use risk-based step-up checks and strict route budgets instead, while accepting the higher policy and on-call burden. The trade-off is reach versus a smaller fraud surface, not security versus no security.
False positives count.
The following Go sketch shows the boundary. It leaves counter storage and geo intelligence behind interfaces because correctness depends on their consistency and data provenance, not on a particular database or provider.
package otp
import (
"context"
"errors"
"time"
)
var ErrRequestDenied = errors.New("request cannot be accepted")
type Request struct {
AccountID, PhoneE164, SourceIP, DeviceID string
PhoneRegion, IPRegion string
}
type Rule struct {
Key string
Limit int64
Window time.Duration
}
type BudgetStore interface {
// Reserve increments every rule only if all rules remain within limit.
Reserve(context.Context, []Rule, time.Time) (bool, error)
}
type Decision struct {
Allowed bool
Reason, AccountID, PhoneE164, PhoneRegion, IPRegion string
PolicyID, PolicyVersion string
DecidedAt time.Time
}
type AuditSink interface {
Record(context.Context, Decision) error
}
type Gate struct {
Budgets BudgetStore
Audit AuditSink
AllowedRegion map[string]bool
PolicyID, PolicyVersion string
Now func() time.Time
}
func (g *Gate) Admit(ctx context.Context, r Request) error {
now := g.Now()
d := Decision{AccountID: r.AccountID, PhoneE164: r.PhoneE164,
PhoneRegion: r.PhoneRegion, IPRegion: r.IPRegion,
PolicyID: g.PolicyID, PolicyVersion: g.PolicyVersion, DecidedAt: now}
if !g.AllowedRegion[r.PhoneRegion] {
d.Reason = "destination_region_not_allowed"
_ = g.Audit.Record(ctx, d)
return ErrRequestDenied
}
rules := []Rule{
{Key: "phone:" + r.PhoneE164, Limit: 3, Window: 10 * time.Minute},
{Key: "account:" + r.AccountID, Limit: 5, Window: 30 * time.Minute},
{Key: "device:" + r.DeviceID, Limit: 8, Window: time.Hour},
{Key: "ip:" + r.SourceIP, Limit: 20, Window: 10 * time.Minute},
{Key: "region:" + r.PhoneRegion, Limit: 1000, Window: time.Hour},
}
allowed, err := g.Budgets.Reserve(ctx, rules, now)
if err != nil || !allowed {
d.Reason = "rate_or_budget_unavailable"
_ = g.Audit.Record(ctx, d)
return ErrRequestDenied
}
d.Allowed, d.Reason = true, "policy_satisfied"
if err := g.Audit.Record(ctx, d); err != nil {
return ErrRequestDenied
}
return nil
}
Those numbers are examples, not universal settings. Derive production limits from observed legitimate retry behavior, the smallest acceptable abuse budget, and the support consequences of false positives. A limit without an owner and an expiry date tends to become folklore.
Do not log the OTP itself. Minimize retention of phone numbers and IP addresses, restrict access, and document retention against applicable legal and security requirements. Compliance evidence should include the policy identifier and version, decision time, normalized destination region, signal categories, decision reason, and downstream message identifier; it should also make deletion and access controls enforceable.
Choose operational ownership before implementation
Buy-versus-build is not a philosophical contest. The relevant question is who owns policy correctness, atomic admission, carrier obligations, evidence retention, and the pager when a dependency degrades. A managed component can reduce undifferentiated operations, but it does not transfer accountability for the application's admission policy. A self-hosted component can improve control and portability, but its state consistency and on-call cost are real capacity requirements.
| Approach | Control boundary | On-call load | Lock-in surface | Evidence question |
|---|---|---|---|---|
| Managed risk and delivery | Policy and telemetry cross an external boundary | Lower for delivery internals, material for policy | Rules, event schema, routing behavior | Can decisions and policy versions be exported on schedule? |
| Self-hosted admission, external delivery | Application owns risk; gateway owns carrier handoff | Higher for counters, data, failover | Delivery API and route behavior | Can atomic reservations and decisions be reconstructed? |
| Multiple gateways, one admission layer | Application owns a portable control plane | Highest integration burden | Reduced at send time | Can gateway IDs retain one audit timeline? |
For many teams, the stable boundary is a vendor-neutral admission service in front of delivery adapters. It owns normalization, policy versions, budgets, idempotency, and audit events. Adapters translate an accepted message into gateway-specific requests and delivery statuses. This keeps failover from becoming a bypass: a rejected challenge cannot be retried through a second adapter as if it were new. This design is a poor fit for a very small service with one supported region, modest traffic, and no team able to operate consistent counters; in that case, a managed risk control with exportable decision logs may be the more responsible choice. Conversely, regulated teams that need locally controlled evidence and deterministic policy replay may accept the larger self-hosted on-call load. Neither option removes the need to set the application's maximum admitted exposure.
Ownership stays put.
Idempotency matters here. Give one logical challenge a stable identifier, and make enqueue and retry operations reuse it. A network timeout should lead to status reconciliation before a fresh send, because blindly resubmitting can create duplicate messages and double consumption of rate and spend budgets. Keep verification attempts separately limited; blocking sends while permitting unlimited code guesses only moves the attack.
Use neutral client responses for both existing and nonexistent accounts, and keep detailed denial reasons internal. The OWASP Authentication Cheat Sheet recommends generic authentication responses to reduce account enumeration. Couple that with latency testing: visibly faster rejection of nonexistent accounts can disclose the same fact even when the text matches.
Verify the guardrails without sending real messages
Test the admission service with a fake queue and a deterministic clock. The important cases are concurrency at the exact ceiling, window rollover, duplicate challenge identifiers, conflicting phone and IP regions, unavailable counter storage, and policy-version changes during traffic. A test that sends requests serially will miss the race that atomic reservation exists to prevent.
Run load tests against the control plane before connecting a delivery adapter. Assert that accepted plus denied equals attempted, reservations never exceed each configured hard budget, and every accepted request has exactly one auditable decision. Then test a sandbox or sink destination through the adapter, where available, to validate status correlation without generating traffic to arbitrary subscribers.
Observe rates, not just totals. Alert on send-to-verification conversion by route and country, unique destinations per source, denial reasons, budget utilization, queue age, delivery failures, and the age of geo or numbering data. The principal SLO should describe legitimate users completing a challenge within an agreed latency, while a separate safety objective caps admitted exposure. Error-budget policy then has teeth: exhausting the safety budget disables or tightens affected destinations instead of asking an operator to watch a rising chart.
Canary policy changes. Evaluate the proposed policy in shadow mode against live request features, without sending additional messages, and compare allow and deny decisions. Shadow evaluation does not prove that blocked users would recover, so pair it with support-volume review and synthetic journeys from supported US and EU regions. Do not weaken a global ceiling merely because a regional canary looks quiet.
Three words: test the ceiling.
Roll back policy, never the audit trail
A rollback must be a versioned policy transition, not an emergency edit to scattered constants. Retain the prior allowlist and thresholds, require an approver for expansions, and record which version evaluated each request. If a new rule creates false positives, restore the previous signed configuration while preserving decisions produced under the short-lived version. Evidence that disappears during rollback is not useful evidence.
Dependency behavior needs an explicit runbook. If the budget store or mandatory audit sink is unavailable, deny new OTP sends and surface a generic retry response; do not fall through to the gateway. Existing sessions and recovery channels should follow their own reviewed policies. Meanwhile, the settled-payment receipt queue continues under its separate capacity allocation and retry rules, which prevents an authentication control-plane failure from suppressing transaction communication.
For a suspected attack, freeze allowlist expansion, lower the affected route budget, invalidate queued OTP work that has exceeded its challenge lifetime, and reconcile accepted challenge IDs against gateway message IDs. Do not purge decision records. Recovery begins when conversion, destination fan-out, and budget consumption return inside the service's defined bands, followed by a deliberate policy change rather than an undocumented switch flip.
The decision rule is strict: no OTP leaves the system unless the application can atomically reserve bounded capacity and later explain the decision. Geographic allowlists reduce the reachable surface, layered limits constrain distributed attacks, and isolated traffic classes protect payment receipts; none of those controls is credible without verification, ownership, and retained evidence.
References
- NIST Special Publication 800-63B, Digital Identity Guidelines: Authentication and Authenticator Management: https://pages.nist.gov/800-63-4/sp800-63b.html
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Multifactor Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
- ITU-T Recommendation E.164, The international public telecommunication numbering plan: https://www.itu.int/rec/T-REC-E.164
- CTIA Messaging Principles and Best Practices: https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
- Amazon Simple Email Service documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.