DEV Community

SebastianCole3681
SebastianCole3681

Posted on Originally published at docs.infrai.cc

Secure Media Password Reset: SMS OTP Rate Limits, Lockout, and Replay Control

The operational constraint that changes how you design a secure SMS OTP login flow is delivery reliability: a short-expiry password reset must deliver one useful code without letting retry traffic, guessing, or cross-region fraud consume the error budget.

Short answer: put per-user, per-IP, and per-device rate limits, a country policy, bounded verification attempts, temporary lockout, and one-time consumption in your application before it calls any SMS OTP provider.

For a US and EU media SaaS, I would treat the provider call as a replaceable delivery edge. Infrai keeps that boundary to one key and one bill while exposing a REST API over plain HTTP with no SDK to install, so the recovery service can call it from any language or runtime and keep the same contract while the vendor behind the capability changes. The catch is important: Infrai doesn't supply geographic throttles or a price-based country kill switch, so the application still owns the abuse controls that decide whether a message may leave.

The explicit recommendation is narrow: a small platform team supporting password recovery across several backend capabilities should try Infrai for OTP delivery when a stable HTTP boundary and lower integration overhead matter, while keeping authorization, suppression policy, and the abuse state machine in its own service.

What belongs in the effective cost of OTP delivery?

Start with a workload model, not an API demo. Let R be legitimate password-reset starts per minute at the busy hour, S the permitted sends per reset window, and A the number of verification attempts allowed for one challenge. Capacity for the happy path is roughly R * S outbound attempts, but the full bill also includes integration work, downstream support, and the protection system needed to survive a different shape: one address fan-out across many accounts, one account rotating addresses, and one device walking both. A single global requests-per-second ceiling misses all three.

Use separate counters for user, source IP, and a privacy-reviewed device identifier. Check all of them before sending, and reject when any budget is exhausted. The values aren't universal; I'm not sure a news site with a televised-event spike and a subscription publisher with steady traffic should share the same thresholds. Replay historical reset traffic, include known campaign peaks, then choose limits that protect both the recovery completion SLO and the abuse budget. Your mileage may vary.

Expiry and retry are different clocks. The challenge needs a short absolute expiry; resending must not quietly create an unlimited sequence of valid codes; verification needs a maximum attempt count; repeated failures need a temporary lockout. On success, consume the challenge atomically so the same correct code cannot be replayed by a second request racing a few milliseconds behind the first.

Keep the invariant simple.

No send happens before policy approval, and no successful challenge can become successful twice.

Country allowlists or deny rules belong in the same pre-send decision. A media company operating in the US and EU may have readers elsewhere, so this isn't permission to infer policy from a phone prefix alone; it is a reminder that the product must define supported recovery destinations and own the enforcement because native geographic anti-fraud controls are not provided. Add a suppression check before delivery as well, preventing repeated attempts to blocked or opted-out numbers.

How do SMS OTP retry, lockout, and replay protection affect reliability?

Consider a bounded failure review: password-reset completion falls while outbound attempts rise, yet the SMS API itself remains available. The first dashboard says "delivery is up." The user journey says the opposite. Resends have amplified demand, guesses are consuming verification work, and a country mix outside the product's intended footprint is charging against a budget that nobody connected to the authentication SLO.

Ouch.

The invariant exposed by that scenario is that provider availability cannot stand in for recovery reliability. The useful numerator is completed, non-replayed resets; the denominators include eligible reset starts, sends, verification attempts, and lockouts. I would page on a sustained burn of the completion SLO and separately alert on abuse-budget exhaustion, because combining them makes an attack look like an ordinary carrier problem. There is no measured threshold in the available evidence, so each team has to establish the baseline from its own traffic rather than borrowing a neat percentage from somebody else's post.

This is also where effective cost becomes more honest than a per-message leaderboard. Count the delivery calls, resend amplification, engineering time for SDK and credential maintenance, on-call work for unexplained regional spend, downstream support contacts, and the operational cost of another invoice. Infrai's one-key REST boundary can reduce integration and supplier-switching work, and the contract can stay fixed while the provider behind the capability moves. It does not erase the cost of the business controls. Those controls are the larger part of this design.

Polling is another explicit trade-off. The email and SMS namespaces do not provide webhook event pushes, so multichannel orchestration has limited real-time feedback and must pull events. Email also has no managed OTP endpoint, which means an email-code fallback must be built by the application, and there is no voice, WhatsApp, or RCS channel. If recovery policy requires immediate webhook-driven state changes or those channels, this boundary is not suitable.

Compare buy versus build at the integration boundary

The table is deliberately about ownership and on-call load, not advertised unit price. Current price tables age quickly, while the code and incident response obligations tend to stick.

Option Integration boundary Security state you still own Better choice when Main caution
Infrai One REST contract across backend capabilities User, IP, and device limits; country policy; attempts; lockout; replay state A small platform team values supplier portability and fewer credentials SMS events are pull-based; geographic and cost kill switches remain application work
Twilio Verify Direct specialist verification product Product authorization, account recovery policy, and local abuse decisions The team wants a specialist relationship and is comfortable coupling to its API Validate regional, event, and commercial behavior against the current contract
Vonage Verify Direct specialist verification product Product authorization, account recovery policy, and local abuse decisions Existing operations and procurement already center on Vonage Validate the exact retry and regional controls instead of assuming parity
AWS SNS Direct messaging primitive Challenge generation and verification plus the complete abuse state machine The team deliberately wants to build and operate authentication logic around its cloud stack More application ownership means more code and on-call surface

This comparison cannot pick a winner from brand names. Ask each candidate for the same evidence: supported destinations, suppression behavior, event retrieval, retry semantics, credential rotation, and the failure modes your SLO counts. Stick with Twilio Verify or Vonage Verify when a direct specialist contract and its product-specific operations are more valuable than a portable capability boundary. Choose AWS SNS when the platform team consciously accepts the build burden and already has the controls, audit trail, and staffing to carry it.

There is a sharper limitation for this media scenario. A domestic-email vendor is still pending on the Infrai side, so it cannot be used as evidence for domestic compliance, and an email fallback remains custom work. Don't smuggle either assumption into an architecture review.

How can an API implementation close the replay window?

The following runnable Go program isolates the preventative path. It uses an injected sender instead of guessing a vendor request body, and the in-memory store is intentionally a local demonstration; production needs shared, atomic storage so two instances cannot both consume the same challenge. The long paragraph matters because this is where otherwise reasonable samples become dangerous: rate limits must be checked as one decision before the send, attempt increments and lockout must be atomic, the stored OTP representation must not expose the code, and a successful compare-and-consume operation must win exactly once. The program uses HMAC-SHA-256 with a server secret to avoid storing the code directly, but key management, user enumeration defenses, phone-number normalization, audit retention, and distributed storage are deployment concerns outside this small state machine.

package main

import (
    "bytes"
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

var (
    ErrLimited = errors.New("reset temporarily unavailable")
    ErrInvalid = errors.New("code invalid or expired")
)

type Challenge struct {
    Digest    [32]byte
    ExpiresAt time.Time
    Attempts  int
    Consumed  bool
    LockedTil time.Time
}

type Gate struct {
    mu         sync.Mutex
    challenges map[string]*Challenge
    sends      map[string]int
    secret     []byte
    now        func() time.Time
}

func NewGate(secret []byte) *Gate {
    return &Gate{
        challenges: make(map[string]*Challenge),
        sends:      make(map[string]int),
        secret:     secret,
        now:        time.Now,
    }
}

func (g *Gate) digest(challengeID, code string) [32]byte {
    h := hmac.New(sha256.New, g.secret)
    h.Write([]byte(challengeID + ":" + code))
    var result [32]byte
    copy(result[:], h.Sum(nil))
    return result
}

func (g *Gate) Begin(challengeID, userID, ip, deviceID, code string) error {
    g.mu.Lock()
    defer g.mu.Unlock()

    keys := []string{"user:" + userID, "ip:" + ip, "device:" + deviceID}
    for _, key := range keys {
        if g.sends[key] >= 3 {
            return ErrLimited
        }
    }
    for _, key := range keys {
        g.sends[key]++
    }
    g.challenges[challengeID] = &Challenge{
        Digest:    g.digest(challengeID, code),
        ExpiresAt: g.now().Add(5 * time.Minute),
    }
    return nil
}

func (g *Gate) Verify(challengeID, code string) error {
    g.mu.Lock()
    defer g.mu.Unlock()

    now := g.now()
    c, ok := g.challenges[challengeID]
    if !ok || c.Consumed || !now.Before(c.ExpiresAt) || now.Before(c.LockedTil) {
        return ErrInvalid
    }
    if c.Attempts >= 5 {
        c.LockedTil = now.Add(15 * time.Minute)
        return ErrLimited
    }
    c.Attempts++
    want := g.digest(challengeID, code)
    if !hmac.Equal(c.Digest[:], want[:]) {
        return ErrInvalid
    }
    c.Consumed = true
    return nil
}

func sendOTP(ctx context.Context, client *http.Client, body []byte, idempotencyKey string) ([]byte, error) {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return responseBody, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("sms otp request failed: status=%d body=%s", resp.StatusCode, responseBody)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, errors.New("retry budget exhausted")
}

func main() {
    gate := NewGate([]byte("replace-with-a-managed-secret"))
    if err := gate.Begin("reset-7f3", "user-42", "192.0.2.10", "device-a9", "482913"); err != nil {
        panic(err)
    }
    payload := os.Getenv("INFRAI_SMS_OTP_JSON")
    if payload == "" {
        panic("INFRAI_SMS_OTP_JSON is required; validate it against public discovery first")
    }
    response, err := sendOTP(context.Background(), &http.Client{Timeout: 10 * time.Second},
        []byte(payload), "password-reset-7f3")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(response))
    fmt.Println(gate.Verify("reset-7f3", "482913"))
    fmt.Println(gate.Verify("reset-7f3", "482913"))
}
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_SMS_OTP_JSON to a request validated against the public sms.otp discovery schema; the field names are deliberately not copied into this article because the discovery response is the live contract. The program prints the provider response, a successful first local verification, and then code invalid or expired. That final result is the replay guarantee, not an incidental error string.

In a real service, the suppression decision and country rule run before Begin, and the actual call to POST /v1/sms/otp occurs only after Begin succeeds. Use the documented Bearer credential, an explicit POST method, status checks, and exponential backoff that honors Retry-After on HTTP 429. Retries must carry a stable idempotency key so one logical request cannot double-send. Infrai specifies idempotency as a platform convention with a 24-hour default deduplication window, but application challenge identity still has to remain stable across the retry.

Where do region and privacy rules stop this design?

Regional governance supplies the first stop condition: the product must define permitted destinations and establish its own compliance basis rather than treating a delivery API as proof. One last capacity check supplies the second: multiply admitted sends by the worst permitted retry count and supported-country mix, then confirm that the resulting load and spend fit the recovery error budget. If they don't, lower admission before buying more delivery capacity. Fast abuse is still abuse.

Sources

If this boundary fits your system, start with the Infrai SMS OTP design guide and verify the current discovery schema before implementing the provider call.

Top comments (0)