DEV Community

UlyssesDonovan1529
UlyssesDonovan1529

Posted on

SMS OTP Abuse Explained — Country Guardrails Against Toll Fraud

TL;DR: For a fintech signup flow that sends a verification link by SMS, the least complex defensible design is an explicit country allowlist plus layered limits on the account, destination, network source, and device. Check those controls before asking an SMS gateway to send. Issue an opaque, single-use, short-lived token only after the request passes, and keep the gateway behind a tiny adapter so the abuse policy does not depend on a provider.

This design does not promise to identify every attacker. It limits how much one signal can cost, makes unusual traffic visible, and keeps a false positive from becoming a permanent lockout. The integration stays small enough to move from a notebook-shaped prototype into a service without burying the important policy in SDK calls.

For this example, a customer enters a phone number while opening a US or EU fintech account. The application resolves an account identifier, network source, device identifier, and destination country; the risk gate evaluates them; then a token service creates the verification link. Only the final adapter can spend money by submitting a message.

That ordering matters.

How can country limits prevent SMS OTP abuse and toll fraud?

Treat send verification link as a billable security action, not a form submission. A country allowlist is the coarse boundary: if signup is offered only in selected countries, requests outside that set should never reach the messaging adapter. Geo blocking alone is weak because a request location and a phone destination describe different things. Keep both signals, but make the destination country the direct policy input and the request location a risk signal.

Then apply several budgets. A single per-IP limit misses distributed traffic. A single per-phone limit lets an attacker spray many destinations. An account limit by itself is ineffective before an account is fully established. The useful shape is an intersection: each request must fit inside every applicable budget.

One key is never enough.

For a starting policy, I would test one send per destination per 60 seconds, five per destination per hour, ten per account per hour, 20 per device per hour, and 30 per network source per hour. Those are example configuration values, not universal recommendations. Signup volume, retry behavior, accessibility needs, carrier latency, and acceptable fraud exposure should determine the deployed values. Put them in configuration and evaluate them against replayable traffic.

Return the same public response for accepted and rejected destinations. The internal reason belongs in telemetry. Exposing whether a number, account, or country is eligible can turn the endpoint into an enumeration tool.

Keep the denial quiet.

A minimal policy gate in Python

The example below is runnable with the Python standard library. Its in-memory counter and token store make the control flow visible; production instances need shared, atomic storage so concurrent workers enforce one budget. The messaging class deliberately has one method. That narrow boundary keeps gateway integration effort out of the risk logic.

from __future__ import annotations

from dataclasses import dataclass
from hashlib import sha256
from secrets import token_urlsafe
from threading import Lock
from typing import Protocol
from urllib.parse import urlencode


class SmsGateway(Protocol):
    def send(self, destination: str, body: str) -> None:
        ...


class RecordingGateway:
    def __init__(self) -> None:
        self.messages: list[tuple[str, str]] = []

    def send(self, destination: str, body: str) -> None:
        self.messages.append((destination, body))


@dataclass(frozen=True)
class SendRequest:
    account_id: str
    destination: str
    destination_country: str
    source_ip: str
    device_id: str


@dataclass(frozen=True)
class Limit:
    name: str
    value: str
    maximum: int
    window_seconds: int


class FixedWindowStore:
    def __init__(self) -> None:
        self._counts: dict[tuple[str, str, int], int] = {}
        self._lock = Lock()

    def consume_all(self, limits: list[Limit], now: int) -> tuple[bool, str]:
        keys = [
            (limit.name, limit.value, now // limit.window_seconds)
            for limit in limits
        ]
        with self._lock:
            for key, limit in zip(keys, limits):
                if self._counts.get(key, 0) >= limit.maximum:
                    return False, f"rate:{limit.name}"
            for key in keys:
                self._counts[key] = self._counts.get(key, 0) + 1
        return True, "accepted"


class TokenStore:
    def __init__(self) -> None:
        self._tokens: dict[str, tuple[str, int, bool]] = {}

    def issue(self, account_id: str, ttl_seconds: int, now: int) -> str:
        raw_token = token_urlsafe(32)
        digest = sha256(raw_token.encode()).hexdigest()
        self._tokens[digest] = (account_id, now + ttl_seconds, False)
        return raw_token

    def redeem(self, raw_token: str, now: int) -> str | None:
        digest = sha256(raw_token.encode()).hexdigest()
        record = self._tokens.get(digest)
        if record is None:
            return None
        account_id, expires_at, used = record
        if used or now > expires_at:
            return None
        self._tokens[digest] = (account_id, expires_at, True)
        return account_id


class VerificationService:
    PUBLIC_RESPONSE = "If the request is eligible, a link will arrive shortly."

    def __init__(
        self,
        gateway: SmsGateway,
        counters: FixedWindowStore,
        tokens: TokenStore,
        allowed_countries: set[str],
        verification_base_url: str,
    ) -> None:
        self.gateway = gateway
        self.counters = counters
        self.tokens = tokens
        self.allowed_countries = allowed_countries
        self.verification_base_url = verification_base_url

    def request_link(self, request: SendRequest, now: int) -> tuple[str, str]:
        country = request.destination_country.upper()
        if country not in self.allowed_countries:
            return self.PUBLIC_RESPONSE, "country_denied"

        limits = [
            Limit("destination_minute", request.destination, 1, 60),
            Limit("destination_hour", request.destination, 5, 3600),
            Limit("account_hour", request.account_id, 10, 3600),
            Limit("device_hour", request.device_id, 20, 3600),
            Limit("source_hour", request.source_ip, 30, 3600),
        ]
        accepted, reason = self.counters.consume_all(limits, now)
        if not accepted:
            return self.PUBLIC_RESPONSE, reason

        raw_token = self.tokens.issue(request.account_id, 600, now)
        link = f"{self.verification_base_url}?{urlencode({'token': raw_token})}"
        self.gateway.send(request.destination, f"Verify your signup: {link}")
        return self.PUBLIC_RESPONSE, "sent"
Enter fullscreen mode Exit fullscreen mode

Keep the internal outcome away from the client. In a real handler it becomes a low-cardinality event such as sent, country_denied, or rate:destination_hour; the HTTP response remains stable. Replace the example's caller-supplied country with a server-derived value from normalized destination data. A client-controlled country field is metadata, not enforcement.

The fixed-window algorithm is intentionally plain. Its limitation is boundary behavior: it can admit two bursts close together when one window ends and another begins. A token bucket smooths bursts but requires more state and careful atomic updates. A rolling log offers precise recent-history decisions at higher storage and cleanup cost.

Counter model Integration effort Useful when Main trade-off
Fixed window Low A first enforceable policy Boundary bursts
Token bucket Medium Small legitimate bursts are expected More state transitions
Rolling log High Exact recent history matters Storage and cleanup work

Do not rewrite the signup flow to change algorithms. Put the choice behind the consume_all contract.

Evaluate the gate before tuning it

A rate limit without an evaluation set is a guess that calcifies. Keep policy evaluation separate from UI experiments because the failure costs differ: blocking a legitimate applicant hurts conversion, while allowing automated sends creates direct spend and can damage delivery quality. The harness should replay decisions without sending messages.

Replay first.

Start with synthetic cases that exercise boundaries: the first allowed request, the second request at 59 seconds, a request at 60 seconds, six destinations from one account, many accounts from one network source, and a disallowed destination country paired with an allowed request location. A particularly revealing fixture combines an allowed US destination, an EU network source, a new device, and the sixth request for the same account. The allowlist should pass it, while the account budget should deny it; changing the network country alone should not reverse that decision. Add production-derived aggregates only after removing raw phone numbers and token material from the fixture. This case forces the test to distinguish eligibility from risk instead of collapsing every geographic mismatch into one block.

The important assertions are small and sharp. No denied case calls gateway.send. The same public message appears for every outcome. A token redeems once, expires at the configured boundary, and cannot verify a different account. Concurrent requests cannot exceed the shared budget.

For policy selection, compare candidate configurations on two axes: legitimate signup attempts delayed and send attempts stopped before the adapter. Also track retries that later succeed, because an initial denial is not equivalent to a lost applicant. This is where a notebook earns its keep: load replay rows, run several configurations, inspect false-positive clusters, then export the chosen values as reviewed configuration.

Do not optimize only the total send count. An attacker can distribute a low rate across many keys, so group evaluation by destination country, network, device, account age, and outcome. Avoid high-cardinality labels in metrics, though. Raw phone numbers, IP addresses, device IDs, and tokens belong in access-controlled event storage with a defined retention policy, not in metric dimensions.

Country policy is a product boundary

A US/EU label is too vague for enforcement. Store the actual destination-country codes allowed by the signup program, version the set, and record which policy version made each decision. A phone destination and network location can disagree, so they should not be treated as interchangeable proof of residence.

What should a mismatch do? A destination outside the service area can be denied before token creation. A network source outside the expected region may justify a smaller budget or an additional review step rather than automatic denial, because legitimate travel and corporate networks exist. Hard eligibility stays in the allowlist; softer evidence stays in a risk decision.

Consent and message handling also need design attention. CTIA publishes messaging interoperability and compliance material for the US ecosystem. Those practices can inform message content, sender behavior, and operational review; an abuse limiter does not replace messaging obligations. For every supported destination, the team still has to identify the applicable program and legal requirements.

Keep a non-SMS recovery path under separate policy. Email may be useful in some recovery designs, but it should not silently inherit the SMS decision or act as an unlimited fallback. A fallback doubles the surfaces an attacker can trigger, so give it independent budgets and audit events. This architecture is a poor fit when the organization cannot maintain shared counters, normalize destinations, or operate a verification-token store; in that case, adding more signals creates an appearance of control without dependable enforcement.

Integration effort lives at the boundaries

Most gateway adapters need little application surface: submit a destination and message, capture a provider-neutral request identifier if available, and translate the result into a small internal status model. Keep credentials, retries, and response parsing inside that adapter. The policy gate should understand sent, temporary_failure, and permanent_failure, not a collection of remote error strings.

Retry placement is a costly trap. Retrying the entire signup command may issue a fresh token and a second SMS after an ambiguous timeout. Give each accepted attempt an idempotency key, persist the intent before dispatch, and let one delivery worker own retries. A retry of the same intent should preserve the budget decision and message identity; a user-initiated resend is a new intent and consumes the resend budget.

Retries are sends too.

This boundary makes a provider change contained. The risk store, token lifecycle, tests, and signup handler remain fixed. Only the adapter and its contract tests move. For a small team, that containment is more valuable than exposing every gateway feature through the application.

Keep message bodies compact and avoid putting account details in them. The verification URL should carry an opaque token, while server-side state binds that token to the account and intended action. Log the token digest only if an operational need justifies it; never log the usable link.

Operate it as a spending control

Before launch, verify that the allowlist is explicit, limits use shared atomic state, the token is single-use, and the send intent is idempotent. Exercise timeout behavior at the adapter boundary. Confirm that denied requests produce no queued delivery and that dashboards show outcomes by country and policy version without embedding personal identifiers. Then rehearse disabling one country or tightening one budget through reviewed configuration.

Watch attempted sends to completed verifications, denials to later successful signups, destinations per account, accounts per device, and adapter submissions per send intent. No one ratio proves abuse. Together they show where to inspect. Alerts should point to a runbook that can reduce exposure without editing code, and every emergency policy change should have an owner, an expiry, and a review trail.

Cost belongs in the evaluation, but price is not the architecture. Count adapter submissions and retries as spend-bearing events, assign a budget to each policy segment, and alert on unexpected slope changes. This stays useful when destination mix or commercial terms change. It also keeps token and model costs out of a path that does not need AI. A deterministic gate is cheaper to evaluate, easier to replay, and easier to explain during a fraud review.

The final design is deliberately modest: explicit eligibility, intersecting budgets, single-use tokens, one messaging boundary, and replayable decisions. It gives a fintech signup team a small integration surface while preserving the controls needed to contain SMS abuse and toll-fraud exposure.

References

Top comments (0)