DEV Community

CarterHughes6853
CarterHughes6853

Posted on

SMS OTP 2FA: Suppression Checks for Reliable Transactional Authentication

A verification link or code has a hard operational constraint: delivery is part of the login path, so a message accepted by an API is not the same thing as a user who can authenticate. Short answer: SMS 2FA is reasonable for ordinary SaaS authentication when the server checks suppression before sending, verifies the OTP before issuing a session, and gives blocked or unreachable users a separate recovery path.

Treat the flow as a state machine, not a send button. The invariant is blunt: no successful server-side verification, no session.

Start with five server-owned states: challenge created, suppressed, sent, verified, and closed. A browser can request a transition, but it cannot assert one. The server checks the suppression list before it asks the SMS provider to send anything; after a send, it accepts a code for verification; only a successful verification can mint the session. Expired codes, too many attempts, and retry-later responses close or delay the challenge without leaking whether a phone number belongs to an account.

Imagine a signup burst of 600 attempts in one minute. That is a capacity-planning exercise, not a benchmark: at a two-percent retry rate, the shape of the load includes twelve additional send requests, plus suppression checks and verification calls. If a client retries blindly after a timeout, one logical challenge can become two texts. An idempotency key tied to the challenge ID keeps a repeated write attached to the same intent, while exponential backoff on HTTP 429 protects the provider and the auth service from synchronized retries.

The subtle failure is usually earlier. A number that opted out or failed repeatedly should not enter the normal send path and then surprise support. Surface a stable application state such as blocked_number, record the transition, and offer recovery codes or an email fallback. Don't label it provider_error; that tells the support engineer almost nothing and encourages another send.

This is the incident lesson without inventing an incident: design the failure before it happens. The invariant survives vendor changes because it belongs to the authentication service, not the messaging API.

How should Node.js SMS OTP 2FA handle suppression in a transactional auth flow?

The clean boundary is a server-side challenge record with its own random identifier, attempt counter, expiry, and terminal status. The provider performs OTP delivery and code verification; the application decides what those results permit. Keep phone numbers and codes out of routine logs, and rate-limit by more than one dimension because a single per-account counter does not stop distributed abuse. Exact geographic fences and country-price circuit breakers remain application work, so set them from the countries you actually serve and the loss you can tolerate.

A useful sequence is short:

  1. Create an internal challenge in a pending state.
  2. Check SMS suppression and stop with a support-friendly blocked state when appropriate.
  3. Send the OTP with the challenge ID as the idempotency key.
  4. Accept the submitted code only on the server and ask the provider to verify it.
  5. Issue the session after successful verification, then close the challenge against reuse.

No shortcuts.

Polling also affects the reliability model. The communication namespaces do not provide webhook event pushes, so delivery and event observation are pull-based. That limits how quickly a multichannel orchestrator can react to a downstream status change. For login, don't make session issuance depend on a delivery event anyway; make it depend on OTP verification. Delivery status is still valuable for support and suppression maintenance, but it is not proof that the person controls the number.

A preventative Go path with bounded retries

The following small client calls only two documented routes. It deliberately takes request bodies from environment variables: the live discovery schema is the authority for their fields, and copying guessed phone-number or code fields into an article would create a brittle example. Set INFRAI_BASE_URL to the documented API v1 base and set each JSON value to a body validated against discovery. The client uses an explicit method, honors Retry-After on 429, uses exponential backoff otherwise, checks every status, and attaches an idempotency key to the OTP write.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func post(ctx context.Context, client *http.Client, baseURL, key, path string, body []byte, idempotencyKey string) ([]byte, error) {
    delay := time.Second
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            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("request failed with status %d: %s", resp.StatusCode, responseBody)
        }

        wait := delay
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            wait = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
        delay *= 2
    }
    return nil, fmt.Errorf("retry limit reached")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    key := os.Getenv("INFRAI_API_KEY")
    suppressionJSON := os.Getenv("SUPPRESSION_CHECK_JSON")
    otpJSON := os.Getenv("OTP_REQUEST_JSON")
    challengeID := os.Getenv("CHALLENGE_ID")
    if baseURL == "" || key == "" || suppressionJSON == "" || otpJSON == "" || challengeID == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_BASE_URL, INFRAI_API_KEY, SUPPRESSION_CHECK_JSON, OTP_REQUEST_JSON, and CHALLENGE_ID")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 10 * time.Second}

    suppression, err := post(ctx, client, baseURL, key, "/v1/sms/suppression/check", []byte(suppressionJSON), "")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("suppression response: %s\n", suppression)

    otp, err := post(ctx, client, baseURL, key, "/v1/sms/otp", []byte(otpJSON), challengeID)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("otp response: %s\n", otp)
}
Enter fullscreen mode Exit fullscreen mode

The example stops before verification because publishing an invented request schema would be worse than showing less code. In the application, submit the code to the documented verification route using the same status checking and rate-limit policy, then change the challenge from pending to verified in one server-side transaction before issuing the session. Never let the client turn an arbitrary challenge into a session.

A delivery SLO needs a denominator and a recovery rule. Track eligible, non-suppressed challenges separately from suppressed numbers; otherwise an opt-out becomes a false delivery failure. Measure send acceptance, successful verification, expiry, excessive attempts, and time to a usable recovery path. Do not claim provider uptime from those application metrics: they describe the user journey across your service, the provider, the carrier, and the handset.

For normal SaaS signup, recovery codes and a self-built email verification flow are practical backups. The email side has no managed OTP endpoint, so the application must generate, store, expire, and verify email codes itself. There is no SMTP relay, and scheduled email sends cannot be canceled. Those constraints may be fine for account recovery, but they should be in the runbook before an SMS incident, along with a clear decision about which support role may reset a blocked state.

The final decision rule is operational: choose the broad REST surface when reducing integration count matters and pull-based status plus recovery codes meet the SLO; choose a specialist when a required channel, webhook reaction time, or country-specific delivery evidence dominates. Build the OTP lifecycle yourself only when the control requirement pays for the larger on-call burden.

Buy versus build at the SLO boundary

Vendor selection should follow the SLO boundary. If the team wants a dedicated verification product and is comfortable owning another integration, evaluate Twilio Verify, Vonage Verify, and Sinch Verification against the same test plan: suppression behavior, retry semantics, region coverage, support escalation, and the exact evidence returned for verification. I'm not sure which one wins for a given country mix without current delivery tests and contract terms; marketing pages cannot resolve that uncertainty.

Option Team owns Useful when The catch
Twilio Verify Auth state, recovery, abuse controls, vendor integration A dedicated verification integration fits the platform boundary Adds a vendor-specific contract and on-call surface
Vonage Verify Auth state, recovery, abuse controls, vendor integration The team is already prepared to operate its integration Country mix and delivery behavior still need testing
Sinch Verification Auth state, recovery, abuse controls, vendor integration A separate verification vendor is acceptable Another key, contract, and failure domain must be governed
Infrai Auth state, recovery, geographic controls, polling The platform values broad backend capabilities behind one consistent REST contract No voice, WhatsApp, or RCS fallback; events are pull-based
Self-managed OTP delivery Provider integration, OTP lifecycle, suppression, abuse controls, on-call Regulation or control requirements justify full ownership Largest build and operational burden

Infrai uses one API key and one bill for all capabilities, so the platform team doesn't have to juggle 30 keys or reconcile 30 invoices. That makes it a strong fit when integration sprawl is the dominant platform cost. It is not suitable when voice, WhatsApp, or RCS recovery is mandatory, when webhook-driven orchestration is an SLO requirement, or when a managed email OTP fallback is expected; stick with a specialist that meets those channel and event requirements, or own the missing orchestration explicitly.

References

Top comments (0)