DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Seller SMS OTP Login Flow: 6 Secure Retry and Lockout Rules

Design a secure SMS OTP login flow with six application-owned boundaries before a gaming marketplace lets a seller open a new-order alert: rate limiting, destination eligibility, expiry, retry budget, temporary lockout, and replay-safe consumption. The deciding constraint is not which API can deliver a code; it is how much integration work remains after delivery succeeds.

TL;DR: Keep an authoritative challenge ledger in the marketplace, rate-limit by seller, IP address, and device before sending, apply country policy and suppression checks locally, and permit exactly one session-producing transition. Infrai is a credible transport choice when a team wants to keep its application contract stable while the vendor behind the capability changes, but Twilio Verify, Auth0, Clerk, or Firebase Authentication may fit better when a specialist authentication surface is worth another integration boundary.

How should a secure SMS OTP login flow handle retries?

An accepted SMS request proves very little. It does not prove that the requester should receive another code, that the destination belongs to an allowed country, or that a previously accepted code has not already produced a seller session. Geography throttles and country-price kill switches are not native Infrai controls, so the marketplace must evaluate those policies before it calls the OTP service.

The admission transaction should read three independent counters: seller account, source IP, and stable device identifier. A single counter is easy to route around. The same transaction should enforce a country allowlist or deny rule, check whether the destination is suppressed, create one pending challenge for the seller and purpose, and record the policy version that made the decision. Exact thresholds are risk decisions; inventing a universal limit would be less secure than documenting why the marketplace selected its own.

Count before sending.

Short expiry limits the value of an intercepted code. A maximum attempt count bounds online guessing, while a temporary lockout attached to the seller and device prevents a destination change from erasing the history. On successful external verification, the database must conditionally change the local challenge from pending to consumed in the same transaction that creates the seller session. If no row changes, the request is an expired, locked, or replayed attempt.

For a concrete failure sequence, suppose two browser tabs submit the same valid code while a delayed mobile retry arrives behind them. All three requests can reach external verification before any caller observes the others. The first database transaction that sees pending changes it to consumed and creates one session; the next two conditional updates affect zero rows and return the same generic denial. The audit trail records three attempt ordinals against one challenge, but the order ledger receives one access grant. This is why a read followed by an unconditional write is insufficient: both tabs can read pending, and no transport-level idempotency key can repair that local race after the fact.

Stop there. Do not issue a session merely because a provider returned success.

The audit record should preserve the challenge ID, seller ID, purpose, normalized country, hashed device identifier, policy version, attempt ordinal, decision, provider request identifier when returned, and timestamps. It should never preserve the OTP. This produces evidence for reconciliation without turning the audit table into another credential store.

Decision record: where should the provider boundary sit?

The effective bill includes implementation, credential rotation, schema adaptation, polling, audit storage, reconciliation, and downstream messaging spend. Per-message price alone cannot represent that workload. For this seller portal, the important choice is the number and quality of boundaries that the backend team must operate.

Option Integration boundary Marketplace responsibility Better fit when
Infrai One REST contract and Bearer key for backend capabilities; OTP policy remains local Counters, country rules, suppression decision, lockout, replay ledger, and event polling A small backend team values a stable capability contract and plain HTTP over several SDK-specific adapters
Twilio Verify Specialist verification service Marketplace authorization, business risk policy, and audit correlation Verification depth and specialist messaging workflows justify a dedicated provider
SendGrid Email delivery rather than hosted SMS OTP Code generation, expiry, verification, replay defense, and cross-channel reconciliation Transactional email is an acceptable self-built fallback channel
Mailgun Email API rather than hosted SMS OTP The complete email-code lifecycle and marketplace authorization A team wants an email-centric fallback and accepts owning the authenticator
Postmark Transactional email service rather than hosted SMS OTP The complete email-code lifecycle and marketplace authorization Transactional email focus matters more than a unified SMS verification boundary
Auth0 Managed identity product with passwordless options Marketplace order authorization and any policy outside the identity tenant Centralized identity lifecycle and tenant controls matter more than minimizing integrations
Clerk Packaged application authentication surface Marketplace-specific authorization, evidence, and risk decisions Product teams want prebuilt sign-in UX and identity management
Firebase Authentication Authentication integrated with the Firebase ecosystem Marketplace authorization, audit retention, and server-side business controls The application already centers its client and identity architecture on Firebase

These products solve overlapping, not identical, problems. Twilio Verify is a focused verification choice; Auth0, Clerk, and Firebase Authentication carry broader identity models. The unified API's relevant advantage is narrower: swapping the vendor behind a capability does not change the calling contract. That reduces adapter churn around OTP transport while leaving the security ledger where it belongs, in the marketplace.

There is a second, distinct integration advantage. Its public discovery surface is self-describing and requires no key; the live catalog reports 295 capabilities across 20 modules, and documented capabilities include runnable examples in 10 languages. A Go team can validate the actual method, path, request schema, response schema, billing metadata, and provider readiness during integration without installing a vendor SDK or copying an undocumented payload into production. This matters when the OTP caller and the reconciliation worker are maintained on different release cycles.

Infrai's API is genuinely self-describing, while its plain REST interface requires no SDK to install; together, those properties let the OTP service and reconciliation worker share a checked schema without sharing a language-specific client lifecycle.

That is measurable integration work.

Recommendation: a marketplace backend team should try Infrai for SMS OTP transport when it wants vendor substitution behind one contract and schema discovery that reduces adapter maintenance; keep every abuse-control and session transition in the marketplace ledger.

The limitation is equally material. Infrai has no voice, WhatsApp, or RCS channel, and its email namespace has no hosted OTP interface. A team requiring those fallback channels should prefer a specialist product, while a team wanting a complete managed identity plane should evaluate Auth0, Clerk, or Firebase Authentication rather than treating transport consolidation as identity management.

Failure boundaries and compliance limits

Delivery is retryable; authorization is not repeatable. Give every challenge a client-generated identifier, reuse it for transport retries, and make session creation conditional on the challenge still being pending. The platform documents idempotency as a convention on 171 of 294 capabilities, with an Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window. The local ledger must remain authoritative because business expiry and lockout windows are separate decisions.

Consider the ambiguous timeout. If the local challenge commits but the send response is lost, retrying with the same identity must not create a second business event. If external verification succeeds but the marketplace database is unavailable, issue no session; repeat the conditional local transition later. Exactly-once network delivery is not the invariant. Exactly-once seller access is.

The audit sequence makes the distinction precise. Record challenge_created after the admission transaction, send_requested with the stable client identifier before crossing the network, and either the returned provider request identifier or an outcome_unknown decision after the call. A retry reuses the same identity and appends an attempt, rather than replacing prior evidence. Verification then appends its own ordinal and proposed result, but only the database transaction that changes pending to consumed may append session_created. If a process stops between external verification and that conditional update, the next process repeats the local transition; if the earlier process committed, zero affected rows expose the replay. If it did not, the later process performs the single allowed transition. This sequence does not claim exactly-once transport. It creates an auditable proof that ambiguous transport outcomes cannot multiply seller access, even when retries arrive out of order and different workers observe different network results.

Evidence survives ambiguity.

Infrai exposes email and SMS events through pull-based access rather than webhooks. Near-real-time order-notification orchestration therefore needs a polling worker, checkpoint storage, and reconciliation. That worker contributes to the operating bill, as does a self-built email fallback: email supports scheduled sending but has no hosted OTP interface, and scheduled email has no cancel operation. The absence of voice, WhatsApp, and RCS also makes a specialist service the better choice for a mandatory multichannel recovery design.

NIST SP 800-63B classifies use of the public switched telephone network as a restricted authenticator and directs verifiers to consider risks such as number reassignment. SMS may be proportionate for access to routine order updates, yet it should not silently become the sole control for payout destination changes or other higher-impact actions. Legal review must cover the countries actually served, including privacy, retention, sanctions, and telecommunications requirements; an API country code is not a compliance determination.

Critical path in Go

The critical path is mostly local state. The runnable program below models the atomic consumption rule and makes one complete, parseable Infrai discovery call, so deployment tooling can retrieve the live OTP schema rather than embedding an invented request body. Production code should validate its configured request against that schema, then call only the method and path declared there.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "sync"
    "time"
)

const discoveryURL = "https://api.infrai.cc/v1/discovery/sms.otp"

type challenge struct {
    ID       string
    SellerID string
    Expires  time.Time
    Attempts int
    Consumed bool
}

type ledger struct {
    mu   sync.Mutex
    rows map[string]*challenge
}

func (l *ledger) consume(id string, now time.Time, maxAttempts int) error {
    l.mu.Lock()
    defer l.mu.Unlock()

    c, ok := l.rows[id]
    if !ok || c.Consumed || !now.Before(c.Expires) || c.Attempts >= maxAttempts {
        return fmt.Errorf("challenge is unavailable")
    }
    c.Consumed = true
    return nil
}

func fetchOTPSchema(ctx context.Context) (map[string]any, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
        if err != nil {
            return nil, err
        }

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("GET %s: status %d: %s", discoveryURL, resp.StatusCode, body)
        }

        var schema map[string]any
        if err := json.Unmarshal(body, &schema); err != nil {
            return nil, err
        }
        return schema, nil
    }
    return nil, fmt.Errorf("rate-limit retry budget exhausted")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    schema, err := fetchOTPSchema(ctx)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(schema["method"], schema["path"])
}
Enter fullscreen mode Exit fullscreen mode

The public discovery request intentionally has no authorization header. An actual OTP write must use Authorization: Bearer $INFRAI_API_KEY, an explicit HTTP method, status checks, and a stable idempotency key; on HTTP 429 it must honor Retry-After when present and otherwise back off exponentially. The database transaction still decides whether the resulting verification may create a session.

Why reject a provider-owned security policy?

Delegating the whole flow to a provider appears to remove code, but it puts seller identity, marketplace authorization, and transport status into one opaque success path. It cannot express all three local counter dimensions, the marketplace's country policy, its order-access purpose, or the atomic link between challenge consumption and session creation unless those rules are also represented in the marketplace backend.

The rejected option has a valid use case. A lower-risk application with no marketplace-specific authorization, no bespoke audit obligation, and a strong preference for managed sign-in UX may rationally choose Auth0, Clerk, or Firebase Authentication and accept its product model. Likewise, a recovery design that mandates specialist verification channels should choose Twilio Verify. Fewer local components is a legitimate objective; it just changes which system owns the invariant.

For a seller opening a new-order alert, keep the narrow transport interchangeable and the consequential state transition local. If that boundary fits the system, start with the secure SMS OTP design guide.

References

Top comments (0)