Short answer: to prevent SMS OTP abuse and toll fraud, issue a code only after a server-side policy has admitted the request by account, session, network, destination country, and global send budget. Default to the countries where the fintech service actually operates, make expansion an explicit risk decision, and preserve the policy decision beside the authentication event. A per-phone-number rate limit alone is weak because an attacker can rotate numbers while the platform keeps paying.

For a login that exposes a receipt after payment settles, the objective is not “deliver every code.” It is to keep unauthorized OTP spend and account access inside declared risk budgets while retaining evidence that explains each allow or deny. Treat delivery as a metered side effect of authentication, not as its first step.

How should rate limits and country allowlists prevent SMS OTP abuse and toll fraud?

The expensive operation is the outbound message. If the system performs it before correlating identity, source network, session, destination prefix, and aggregate demand, an attacker gets a fresh quota merely by changing one field. CAPTCHA can raise automation cost, but it does not create a spending boundary and should not be the only gate.

Stop the send first.

The destination deserves special care. A country allowlist should come from the product's supported-market policy, not from an IP geolocation guess. Origin IP and the telephone numbering plan answer different questions: where the request appears to come from, and where the billable message terminates. A mismatch is a risk signal, not proof; travelers, gateways, and mobile networks make that shortcut unsafe.

Bind the challenge to a server-created login transaction and a narrow purpose such as receipt_access. Do not let a client choose an arbitrary destination country or reuse a successful challenge for another action. NIST SP 800-63B treats PSTN out-of-band authentication as restricted and directs verifiers to consider risks such as SIM changes and number porting. SMS should therefore not become the unquestioned recovery path.

That restriction matters.

Put admission control before the message queue

A defensible path is short: normalize the number, resolve its country from the numbering plan, load the tenant's approved markets, consume several rate budgets atomically, create a short-lived challenge, and only then enqueue a send. Store a reason code for every rejection. Keep the OTP secret out of logs and evidence exports.

The exact numbers below are an example policy, not an industry baseline: five sends per account per hour, three per destination per hour, 20 per source network per ten minutes, plus a tenant-wide ceiling sized from expected legitimate peaks. Capacity planning matters. If the normal peak is unknown, a global ceiling is theater; derive it from observed login demand, retry behavior, and explicit headroom, then alert before exhaustion.

The trade-off is deliberate: a tight aggregate ceiling can reject legitimate login bursts, while a loose one leaves a large billable blast radius. A platform team should choose that boundary from demand data and an SLO, document who may raise it, and test the failure response. The five, three, 20, and 1,000 values in this example make the mechanics concrete; they are not evidence that those values fit another service.

package otp import ( "context" "errors" "time" ) type Request struct { TenantID, AccountID, SourcePrefix string DestinationE164, Country, Purpose string } type Decision struct { Allowed bool Reason, PolicyRev string } type Gate interface { Consume(context.Context, string, int, time.Duration) (bool, error) } type Audit interface { Record(context.Context, Request, Decision) error } func Admit(ctx context.Context, r Request, countries map[string]bool, g Gate, a Audit) (Decision, error) { deny := func(reason string) (Decision, error) { d := Decision{false, reason, "otp-2026-01"} return d, a.Record(ctx, r, d) } if r.Purpose != "receipt_access" || !countries[r.Country] { return deny("policy_scope_denied") } checks := []struct { key string limit int window time.Duration }{ {"account:" + r.AccountID, 5, time.Hour}, {"destination:" + r.DestinationE164, 3, time.Hour}, {"source:" + r.SourcePrefix, 20, 10 * time.Minute}, {"tenant:" + r.TenantID, 1000, time.Hour}, } for _, c := range checks { ok, err := g.Consume(ctx, c.key, c.limit, c.window) if err != nil { return Decision{}, err } if !ok { return deny("rate_budget_exhausted") } } d := Decision{true, "admitted", "otp-2026-01"} if err := a.Record(ctx, r, d); err != nil { return Decision{}, errors.New("audit record failed") } return d, nil } 

Production admission must be atomic across the counters, or concurrent requests can all observe spare capacity and overshoot it. The sample expresses the interface and fail-closed behavior; the Gate implementation owns atomicity. It returns an error when the evidence record cannot be written. For a compliance-led workflow, sending without that record creates a gap.

No record, no send.

Keep the public response bland and stable. A caller does not need to learn whether an account exists, a country is blocked, or a budget fired; internal telemetry does. An audit row can hold a pseudonymous account key, transaction ID, policy revision, country, coarse source prefix, decision, reason, and timestamps, subject to the organization's retention rules. It must not hold the OTP.

Make controls observable without leaking secrets

Use separate SLOs for admission availability and admitted-message delivery. Combining them hides the difference between “the policy service could not decide” and “the downstream network did not deliver.” Track request, admission, denial, verification, and delivery-state counts by tenant and country with bounded cardinality. Alert on ratio changes and budget burn, not raw traffic alone.

Destinations per account, accounts per source prefix, and admitted sends per successful verification are useful signals. None proves abuse independently. Together they expose rotation and low-conversion spend patterns while leaving room for legitimate bursts. Phone numbers, codes, and receipt contents do not belong in metric dimensions.

Evidence must be reproducible. Given one audit row, an investigator should identify the policy revision, budgets evaluated, and related login transaction and settled-payment receipt without reconstructing secrets from application logs. Test that property with redacted fixtures.

This is the evidence boundary.

Choose operational ownership, not a logo

The buy-versus-build boundary belongs around commodity delivery and the policy control plane, not around a vague promise to “handle fraud.” A managed sender can operate carrier connectivity; the application still owns who may trigger a message, supported markets, and required evidence.

Capability Keep in the application boundary Delegate only with evidence
Session and receipt-purpose binding Yes; these are domain facts An external sender cannot infer them reliably
Country policy and spend budgets Prefer local policy ownership Require exportable rules, revisions, and decisions
Carrier routing and delivery receipts Usually delegate Evaluate coverage, status semantics, and retry behavior
Audit retention Define schema and retention internally Require export and deletion controls
On-call response Own admission and verification SLOs Define carrier-degradation handoffs

Self-hosting every component increases patching, carrier integration, and on-call load. Buying everything can create lock-in where policy history must remain portable. A practical split keeps a provider-neutral message interface and independently testable admission ledger, while requiring delivery services to return correlation IDs and documented status transitions. Price is secondary to evidence completeness, geographic processing constraints, exit cost, and failure isolation.

Verify the guardrails and rehearse rollback

Test number normalization, unsupported countries, concurrent counter consumption, resend cooldown, purpose binding, and audit-write failure. Then shadow-evaluate a new policy against production-shaped traffic without sending extra messages. Compare decisions, but do not treat historical approval as ground truth; existing abuse may already be present.

Roll out by tenant or stable account hash. Watch the admission error budget, verification conversion, denied-country distribution, and send-budget burn. Every policy change needs a revision and owner.

Rollback is a policy operation too.

Rollback should restore the previous signed policy revision, not bypass admission or expand the country set globally. Emergency overrides should be narrow and time-bound, with an approver, expiry, reason, and audit event. If the policy store or evidence sink is unavailable, fail closed for new sends while preserving alternative authenticators and support procedures appropriate to the risk model. That availability trade-off is explicit and measurable.

The acceptance test is blunt: can the team show why a particular receipt-access OTP was permitted, which limits were charged, what policy version decided, and whether the challenge succeeded, without exposing the code itself? If not, the system has rate limiting but not compliance evidence.

References