DEV Community

CarterHughes6849
CarterHughes6849

Posted on

Secure SMS OTP Login Flow Design: 5 Controls for Automotive Contact Routing

To design a secure SMS OTP login flow for an automotive service portal, accept each code once, then route the authenticated contact form to the correct support queue. The least complex design keeps issuance, verification, and contact routing as separate state transitions backed by one authoritative store, with rate limiting at each exposed boundary.

Short answer: rate-limit both the account and the requester, permit only a small explicitly configured number of guesses, consume a valid code atomically, and apply lockout to the verification challenge rather than blindly freezing the customer's account. Treat delivery retries as message delivery work, never as permission to mint another valid code. The five boundaries are identity, requester, challenge, delivery, and downstream action.

The page arrives after a customer enters the correct code twice and two copies of a service-update request reach different queues. On-call sees a duplicated downstream action, a successful verification counter that increased twice, and no clean signal showing whether the code was consumed before the queue write. That is already too late. The earlier signal should have been a failed atomic-consumption invariant, not a rise in SMS errors.

How should a secure SMS OTP login flow handle retry and lockout?

Start at the business action and walk backward. A contact request should carry a stable request ID. The routing worker records that ID with its result so a queue retry cannot create another case. Verification should produce a short-lived authorization result bound to the challenge, account, and intended action. It should not expose the OTP itself to the routing layer.

Then inspect the transition that matters: pending to consumed. Two concurrent verification calls may both calculate the same hash and both appear correct. Only one may change the record. A compare-and-set update, transaction, or conditional write must make the second caller lose. The attempt increment and the success transition belong in that same operation; splitting them into a read followed by a write leaves a race in which two application instances make independent decisions from stale state. A retry after a timeout must reread the stored outcome instead of assuming the first write failed.

One winner.

This is the invariant:

package otp

import (
    "context"
    "errors"
    "time"
)

var ErrRejected = errors.New("verification rejected")

type Challenge struct {
    ID        string
    AccountID string
    CodeHash  []byte
    ExpiresAt time.Time
    Attempts  int
    Consumed  bool
}

type Store interface {
    // Consume performs one conditional write: pending, unexpired, under the
    // attempt ceiling, and hash-matched. Exactly one caller can succeed.
    Consume(ctx context.Context, challengeID string, candidateHash []byte, now time.Time) (bool, error)
}

func Verify(ctx context.Context, s Store, id string, candidateHash []byte, now time.Time) error {
    ok, err := s.Consume(ctx, id, candidateHash, now)
    if err != nil {
        return err
    }
    if !ok {
        return ErrRejected
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The interface deliberately hides storage mechanics. The database operation behind Consume is the security boundary; a process-local mutex is not, because another instance can receive the replay.

Five boundaries, five different failure modes

Rate limiting is often reduced to an IP counter. That misses shared office networks, carrier-grade NAT, distributed guessing, and targeted harassment of one phone number. Use independent controls whose keys reflect the thing being protected.

Boundary Protects against State and response
Account or phone identifier Focused guessing and SMS flooding Count issuance and verification separately; apply a challenge cooldown
Requester or network source Automated fan-out Throttle bursts without treating an IP address as identity
Challenge Repeated guesses Expire it, cap attempts, and invalidate it after success
Delivery Provider or queue retries Reuse the same delivery job and challenge; do not generate parallel codes
Contact-form action Duplicate support cases Persist an idempotency key with the chosen queue

Exact ceilings are policy, not universal security constants. A team might test an illustrative configuration of five verification attempts and a 10-minute challenge lifetime, but those numbers need threat modeling, abuse data, delivery latency by region, and support impact before production use. Put them in configuration, record their version with each challenge, and test boundary behavior.

The trade-off is blunt. A low account limit slows guessing but lets an attacker deny access by repeatedly requesting codes for a victim. A generous limit improves recovery from delayed messages but expands the guessing window. Combining a modest per-challenge ceiling with progressive issuance cooldowns and a separate requester limit gives operators more control than one global lockout.

Do not reset the attempt count when a customer asks for a resend. That tiny implementation choice can turn the resend button into an unlimited guessing loop.

Make retries boring

There are three retries in this system, and mixing them up causes incidents. A user retry is another candidate code and consumes an attempt. A delivery retry resends the same message job after a transient failure and does not reset security state. A worker retry repeats contact routing with the same idempotency key.

Keep one current challenge per authentication intent. If product policy creates a replacement challenge, invalidate the old one in the same transaction. Otherwise a delayed SMS can deliver an older code that remains valid beside the new one, doubling the useful attack surface and confusing customers.

Regional delivery differences matter operationally across US and EU deployments, but geography should not silently weaken verification. Measure issuance-to-delivery and issuance-to-success distributions by region, without logging codes or full phone numbers. If a region is slow, adjust the reviewed expiry policy or offer a different authenticator; do not add hidden retries that create extra active codes.

NIST SP 800-63B treats use of the public switched telephone network for out-of-band authentication as a restricted authenticator and calls for considering risks such as number reassignment and device swapping. That makes SMS a risk decision, not proof that possession of a number is permanent identity. Sensitive profile changes deserve reauthentication or a stronger recovery path rather than trust inherited from the login code.

Instrument the invariant, not the provider

The first dashboard version often counts sent messages, delivery failures, and verification failures. Useful, but incomplete. The repaired instrumentation follows the state machine and the customer action:

  • issuance outcomes by coarse region and policy version;
  • verification outcomes: expired, attempt-limited, mismatch, consumed, and replayed;
  • time from issuance to first delivery signal and successful verification;
  • duplicate consumption attempts for an already consumed challenge;
  • duplicate routing suppressions by idempotency key;
  • lockout and cooldown rates, followed by eventual successful access.

Never attach the plaintext code, candidate code, or complete phone number to logs, traces, metric labels, or queue payloads. Use opaque challenge IDs and carefully bounded dimensions. Alert on ratios and sustained changes with minimum traffic gates; a raw count will page on normal growth, while a ratio alone becomes noisy when volume is tiny.

The action-oriented page should say which invariant moved, which policy version is affected, and whether duplicate actions escaped suppression. It should link to a runbook that first checks atomic consumption and idempotent routing, then delivery health. Provider status belongs later in the trace because a delivery outage cannot explain two accepted consumptions.

Roll out without teaching production the lesson

Test concurrency, not only happy paths. A useful suite fires many verification requests for the same correct candidate and asserts one successful state transition. It advances a fake clock across expiry, exhausts attempts, replaces a challenge, replays the old code, and retries the queue handler after an injected failure. The contact route must remain singular throughout.

Deploy policy changes behind versioned configuration. Shadow counters can show how many real requests a tighter limit would reject without enforcing it. Move enforcement gradually by region or tenant cohort, watch support contacts and eventual-login rates, and retain a fast rollback to the previous policy version. This is where integration effort pays off: one shared challenge service and one idempotency contract are easier to reason about than limits scattered through web handlers and queue consumers.

Runbooks need a recovery decision too. Clearing every lockout during an incident restores access but also erases the control during the moment attackers may be most active. Prefer narrowly scoped expiry of a faulty policy version, with an audit record, while keeping replay protection intact.

The closing decision is operational: page only when the system risks accepting a replay or emitting a duplicate action, and ticket slower abuse trends for investigation. Thresholds set too low train on-call to distrust the page; thresholds set too high discover the defect from duplicate support cases. Calibrate with observed baseline traffic, minimum sample sizes, and explicit error-budget impact. The goal is not zero rejected logins. It is one authorized action from one consumed challenge, with enough evidence to explain every refusal.

Further reading

RFC 6376 is relevant only if the service also sends email updates: DKIM authenticates an email domain's responsibility for a message, but it does not secure an SMS OTP or replace the challenge controls above.

Top comments (0)