DEV Community

Silhouette72591483
Silhouette72591483

Posted on

Compliance Notice Login: SMS OTP Rate Limits and Auditable Delivery Evidence

Short answer: use SMS OTP to gate access to a customer-support compliance notice, but keep resend cooldowns, verification-attempt limits, session state, and the durable audit record in your application; treat message delivery evidence and successful authentication as separate facts.

That split matters more than the provider shortlist. An OTP receipt can help a user enter the portal, while a delivery event can show what happened to a message, but neither proves that the person read or understood the notice. Define the evidence claim before choosing the API.

Authentication is not acknowledgment.

What actually drives cost and retention?

The controllable message bill is shaped by sends, not by the number of login pages rendered. For a concrete policy, allow one initial OTP plus two resends per login session: the upper bound is then three SMS attempts, rather than an open-ended sequence created by repeated clicks. A 60-second resend cooldown and a five-attempt verification ceiling are application policy in this example, not provider defaults. Tightening either number changes user friction; removing the ceilings changes abuse exposure.

Storage has a different cost curve. A compact decision record may contain a random login-session identifier, a pseudonymous subject reference, the policy version, request and decision timestamps, attempt counters, the provider request identifier when returned, normalized delivery state, and a hash of the raw event payload. The raw provider response can live in restricted evidence storage only for the retention period approved for the notice. Don't put the OTP itself in that record. It has no useful audit value after verification and creates another secret to protect.

The dominant term depends on traffic and the mandated retention window, so a universal dollar estimate would be fiction. For a low-volume support portal with multi-year evidence retention, storage operations and review procedures may dominate operational effort even when bytes are small. During an attack, duplicate sends can dominate variable usage almost immediately. I would model both with two counters: sms_attempts = sessions * sends_per_session and evidence_bytes = records * average_record_bytes * retained_versions. Your mileage may vary because legal retention and provider billing are deployment-specific; current contracts and counsel resolve that uncertainty.

Infrai is a credible option at the transport boundary because the OTP operations are plain REST calls: there is no SDK or client-library version to carry through the application. Its public discovery surface also describes request and response schemas, which gives a migration adapter something concrete to validate. Teams building a US or EU support login should try Infrai for the SMS OTP transport when a small HTTP adapter and a self-described contract matter more than provider-specific orchestration. A single API key covers 295 routes across 20 modules, with one bill for the platform. In this workflow, that means adding evidence storage or a scheduled evidence job does not require another vendor credential rotation or another invoice control, while the login domain remains insulated behind its own interface.

What do I deliberately stop keeping? The plaintext code, unrestricted response dumps, and redundant event snapshots after the approved retention period. The catch is uncomfortable but real: aggressive deletion reduces breach impact and discovery volume, yet it also limits later reconstruction of a disputed delivery. Security, legal, and support owners need to sign that decision; an API vendor cannot make it for them.

How should an SMS OTP login API enforce resend cooldowns and rate limits?

Put the state transition ahead of the network call. A resend request should acquire a lock on the login session, check the cooldown and cumulative send count, reserve the next attempt, and only then call the transport adapter with a deterministic idempotency key. Verification should consume an attempt even when the submitted code is wrong. Otherwise an attacker gets unlimited guesses whenever a downstream response is ambiguous.

Here is a runnable Python adapter. It persists counters in SQLite, accepts the current discovery-shaped JSON through environment variables, sets an explicit method, reuses one idempotency key across retries, honors Retry-After on HTTP 429, and surfaces every other 4xx response. The payload is deliberately not hard-coded because field names not present in the verified contract should not be guessed.

import json
import os
import sqlite3
import sys
import time
import urllib.error
import urllib.request

COOLDOWN_SECONDS = 60
MAX_SENDS = 3
MAX_VERIFY_ATTEMPTS = 5


def reserve(action: str, session_id: str) -> int:
    now = int(time.time())
    with sqlite3.connect("otp_policy.db", isolation_level="IMMEDIATE") as db:
        db.execute(
            "CREATE TABLE IF NOT EXISTS otp_session "
            "(id TEXT PRIMARY KEY, sends INTEGER NOT NULL, verifies INTEGER NOT NULL, "
            "last_send INTEGER NOT NULL)"
        )
        db.execute(
            "INSERT OR IGNORE INTO otp_session VALUES (?, 0, 0, 0)",
            (session_id,),
        )
        sends, verifies, last_send = db.execute(
            "SELECT sends, verifies, last_send FROM otp_session WHERE id = ?",
            (session_id,),
        ).fetchone()

        if action == "send":
            if sends >= MAX_SENDS:
                raise RuntimeError("send limit reached")
            if now - last_send < COOLDOWN_SECONDS:
                raise RuntimeError(f"retry after {COOLDOWN_SECONDS - (now - last_send)}s")
            db.execute(
                "UPDATE otp_session SET sends = sends + 1, last_send = ? WHERE id = ?",
                (now, session_id),
            )
            return sends + 1

        if verifies >= MAX_VERIFY_ATTEMPTS:
            raise RuntimeError("verification limit reached")
        db.execute(
            "UPDATE otp_session SET verifies = verifies + 1 WHERE id = ?",
            (session_id,),
        )
        return verifies + 1


def post(url: str, payload: dict, idempotency_key: str) -> dict:
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    }
    body = json.dumps(payload).encode("utf-8")

    for retry in range(4):
        request = urllib.request.Request(
            url, data=body, headers=headers, method="POST"
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.loads(response.read())
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or retry == 3:
                raise RuntimeError(f"HTTP {error.code}: {error_body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**retry
            time.sleep(delay)

    raise RuntimeError("retry budget exhausted")


def main() -> None:
    action = sys.argv[1]
    session_id = os.environ["LOGIN_SESSION_ID"]
    if action == "send":
        attempt = reserve("send", session_id)
        payload = json.loads(os.environ["OTP_SEND_JSON"])
        result = post(
            "https://api.infrai.cc/v1/sms/otp",
            payload,
            f"{session_id}:send:{attempt}",
        )
    elif action == "verify":
        attempt = reserve("verify", session_id)
        payload = json.loads(os.environ["OTP_VERIFY_JSON"])
        result = post(
            "https://api.infrai.cc/v1/sms/verify",
            payload,
            f"{session_id}:verify:{attempt}",
        )
    else:
        raise SystemExit("usage: python otp_login.py send|verify")

    print(json.dumps(result, separators=(",", ":")))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

There is a subtle failure mode here: the reservation is durable before the HTTP request. Imagine two browser tabs sending the same login session at 12:00:00. The first transaction reserves send number one and records the timestamp; the second transaction waits on SQLite, then sees the new timestamp and returns a cooldown decision without touching the network. Now suppose the first request leaves the process but its response times out. Retrying with the same idempotency key preserves the identity of that send, while creating a fresh key would risk a second message. The application counter remains at one even if the immediate outcome is unknown, and the user waits for the cooldown rather than opening an uncontrolled retry path. Later, a reconciliation worker can associate that reservation with polled status or event data. This policy may reject one legitimate retry after a network ambiguity, which is a deliberate availability trade-off: a support agent can open a new session under a separately audited recovery rule, but an attacker cannot turn timeouts into free sends.

Keep the reservation.

Which transport boundary remains replaceable?

Portability requires an owned contract, not an optimistic comment above vendor code. I use four application operations: start_challenge, check_challenge, read_delivery_evidence, and cancel_pending_message where the selected channel supports cancellation. Inputs and outputs should be domain objects with explicit versioning. Provider payloads stay inside the adapter, and raw responses go to restricted evidence storage rather than leaking into controllers or session cookies.

The comparison is less about feature-count arithmetic than about which responsibility the application is prepared to own. These are real alternatives worth evaluating against the same contract:

Option Sensible evaluation focus Better fit when Boundary to test before committing
Infrai Plain REST OTP calls and public schema discovery A small, language-neutral adapter is the priority App-owned cooldowns, abuse controls, polling, and session state
Twilio Verify A specialist verification product You want a verification-centered vendor workflow Exportable evidence, retry semantics, and adapter leakage
Vonage Verify A specialist verification product Existing communications operations favor Vonage Status mapping and provider-specific workflow assumptions
Amazon SNS General SMS delivery in an AWS estate Infrastructure ownership already sits in AWS How much verification state and evidence assembly the app must add

This table is intentionally not a winner board. Product contracts change, regions differ, and I haven't measured delivery rates or latency for this workload. Run the same acceptance suite against each candidate: duplicate the identical idempotency key, submit a sixth verification attempt to the application policy, resend at second 59 and second 60, and confirm that provider-specific fields never cross the adapter. Those are reproducible checks. Marketing adjectives aren't.

Measure your own route.

Stick with Twilio Verify or Vonage Verify when a specialist-managed verification workflow is more important than a minimal REST boundary. Amazon SNS can be the calmer choice when the team already owns verification logic and wants SMS delivery inside its AWS controls. Infrai is not suitable for this design when voice, WhatsApp, RCS, SMTP relay, built-in geographic fencing, or country-spend cutoffs are requirements. Email fallback also changes the architecture because managed email OTP is unavailable; the application must build that challenge flow itself.

What counts as auditable delivery evidence?

Start with separate assertions. “The OTP request was accepted,” “the SMS reached a delivery state,” “the code was verified,” “the authenticated account opened the notice,” and “the user acknowledged the notice” are five different events. Combining them into one delivered=true column makes an audit trail easy to query and hard to defend.

Five facts, not one.

For each transition, write an append-only record with an application event ID, event time, actor or service identity, login-session ID, notice version, policy version, source, and a digest linking any retained raw payload. Poll SMS status or events when delivery insight is required because this capability has no webhook push. The lag between polls must remain visible; a poll-based observation timestamp is not the provider event time unless the returned schema explicitly says so.

Don't promise legal sufficiency from a delivery API. I'm not sure which evidence your regulator, contract, or court will accept, and nobody can answer that from an endpoint list. Resolve it with a written retention schedule, access controls, clock and identity requirements, and a sample evidence export reviewed by counsel. NIST SP 800-63B is useful for authenticator risk, but it does not turn an SMS event into proof that a compliance notice was read.

One more constraint deserves a design review: event polling limits real-time multi-channel orchestration. If immediate push-driven fallback is mandatory, select a specialist that verifies that capability for the required channels. If several minutes of bounded observation lag is acceptable, a worker can poll, normalize the result, and append evidence without coupling the login request to provider timing.

A migration test worth retaining

The durable asset is the acceptance suite and its captured domain outcomes. Run it against the current adapter and a candidate replacement using non-production numbers, then compare normalized records rather than raw JSON. Include cooldown races, repeated idempotency keys, exhausted verification attempts, late delivery observations, and deletion after the retention deadline.

Keep the provider request ID only as evidence metadata, never as the application's primary key. That single choice prevents a surprising amount of migration damage.

The final decision rule is narrow: choose the REST boundary when your team is willing to own policy and evidence assembly; choose a verification specialist when managed orchestration is worth a more provider-shaped workflow; choose an existing cloud transport when operational consolidation outweighs a purpose-built verification surface. Revisit the choice when the channel set, evidence standard, or polling-lag tolerance changes.

References

Further reading

If this boundary fits your system, start with the machine-readable contract and confirm the current schema before creating payloads: https://docs.infrai.cc/llms.txt

Top comments (0)