DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Governing Next.js Phone Verification Login SMS OTP Resends in Production

TL;DR: In a Next.js phone verification login, treat the SMS OTP resend countdown as a backend admission-control decision, not a browser timer. For marketplace signup, the server should own the challenge state, return a masked destination and retry-after metadata, verify the submitted code, and create the application session only after validation succeeds. Put delivery behind a narrow contract so changing the provider behind that capability does not change the signup code.

The operational recommendation is to launch this as a guarded workflow: country allowlist first, atomic send budget second, verification-attempt budget third, then bounded delivery-status polling. This ordering matters because a successful SMS API response proves neither delivery nor identity, while a polished countdown in Next.js proves almost nothing about abuse control.

Start with the failure signal

The dangerous metric is not raw send failure. It is the gap between accepted send requests, delivered messages, successful code checks, and completed signups. Those are four different states, and compressing them into a single “OTP success rate” leaves the on-call engineer guessing whether the problem is routing, delivery, user entry, or application session creation.

Make the challenge identifier the join key across those states. Persist the masked destination, provider message identifier, country decision, send count, failed-check count, expiry, and next_send_at; never put the full phone number or the code into routine logs. A Next.js server action or API route can return the masked destination and remaining wait, but the browser must not decide whether another send is allowed.

Short timers are deceptive. Two tabs can race, a page refresh can reset local state, and a caller can bypass the page entirely. The backend transition needs an atomic compare-and-set or transaction, because a process-local mutex does not protect two application instances. Pick the actual cooldown and attempt limits from abuse risk, expected support load, and signup SLOs rather than copying unexplained constants from a tutorial.

The server is the clock.

Capacity planning also starts at the policy edge. Estimate peak admitted signups, multiply by the maximum permitted sends per challenge, and add status-poll traffic for every open challenge. During a delivery slowdown, open challenges x polls per challenge can dominate the request stream precisely when downstream capacity is least comfortable. Set a polling deadline and preserve an explicit unknown delivery state instead of polling forever.

How should a Next.js backend govern phone verification login SMS OTP resends?

It should own every rule that changes whether the marketplace is willing to send or accept a code: country allowlists, routing, resend eligibility, maximum attempts, challenge expiry, and session issuance. Provider-side geography or spend protection is not a replacement for those controls. If US and EU compliance requirements differ, make that difference visible in application policy and tests.

The state machine can stay small:

  1. Start checks country policy and abuse limits, reserves a send, calls the selected adapter with an idempotency key, then returns a masked destination and server timestamp.
  2. Resend repeats the same guarded transition; it does not trust a client-supplied countdown.
  3. Verify consumes an attempt, checks the code through the adapter, and marks the challenge verified on success.
  4. Session creation accepts only a verified, unexpired challenge and must be replay-safe.

Fail closed.

For delivery troubleshooting, poll message status or events with bounded backoff. The event model here is pull-based rather than webhook-driven, so any cross-channel orchestration has to include that observation delay in its SLO. Email is not a ready-made OTP fallback in this capability: a team choosing email codes must build that verification path itself. Voice, WhatsApp, and RCS are also outside this surface, which is a real boundary if the product requires automatic channel escalation.

Unknown is valid.

Roll out a safe adapter

The adapter should translate one application request into the current provider schema and return a provider identifier; policy stays above it. The following Go program is deliberately narrow. It sends one OTP request, requires a caller-generated idempotency key, honors Retry-After on HTTP 429, falls back to exponential delay, and surfaces non-success response bodies.

The request JSON remains external because its exact fields must come from the current discovery schema, not from a stale article. Infrai exposes a public, unauthenticated discovery surface with request and response JSON Schema, billing metadata, and runnable examples; its broader catalog reports 295 routes across 20 modules. The useful fit here is integration effort: one REST contract and key can keep the application adapter stable while the service behind the capability changes. Event observation still uses polling, and application-owned country and spend controls remain mandatory.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    base := strings.TrimRight(os.Getenv("SMS_API_BASE"), "/")
    idempotencyKey := os.Getenv("OTP_IDEMPOTENCY_KEY")
    payload := []byte(os.Getenv("OTP_REQUEST_JSON"))
    if base == "" || key == "" || idempotencyKey == "" || !json.Valid(payload) {
        fmt.Fprintln(os.Stderr, "set SMS_API_BASE, INFRAI_API_KEY, OTP_IDEMPOTENCY_KEY, and valid OTP_REQUEST_JSON")
        os.Exit(2)
    }

    body, err := sendOTP(context.Background(), http.DefaultClient, base, key, idempotencyKey, payload)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    _, _ = os.Stdout.Write(body)
}

func sendOTP(ctx context.Context, client *http.Client, base, key, idempotencyKey string, payload []byte) ([]byte, error) {
    endpoint := base + "/v1/sms/otp"

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        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.Second << attempt
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == 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("OTP send failed: status=%d body=%s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("OTP send exhausted retries")
}
Enter fullscreen mode Exit fullscreen mode

Four attempts in this example are a client retry ceiling, not a claim about provider behavior. Production code should also cap total elapsed time through the context. Store the successful provider identifier before replying to the browser, and route form submission through the documented verification operation; only a successful validation may advance the challenge to the session-creation transaction.

Do not treat the adapter as the system of record. If an outbound call succeeds but the application cannot persist its result, retrying with the same idempotency key should not create a second send. Conversely, reserving a send budget before the call needs a defined reconciliation state when the call fails. Those transitions deserve tests because they are where attractive front-end demos become expensive operational ambiguity.

How do the managed options change integration effort?

Twilio Verify, Vonage Verify, AWS End User Messaging SMS, and Infrai can all sit behind the same application boundary, but they represent different ownership choices. Compare their current country support, registration rules, and exact request schemas in official documentation during implementation; those details change and should not be frozen into the policy layer.

Option Integration posture Operational boundary Sensible fit
Twilio Verify Direct integration with a dedicated verification product Provider workflow is explicit in the adapter; replacement means writing and testing another adapter Teams that want a focused verification product
Vonage Verify Direct integration with the Verify API Application still owns marketplace policy while the adapter owns provider translation Teams already operating Vonage communications
AWS End User Messaging SMS Integrate through the AWS service and its access model Messaging joins the existing AWS ownership and credential boundary Platforms already standardized on AWS operations
Infrai Use a plain REST capability behind the application contract One contract can reduce adapter churn, while polling and business-layer controls remain visible obligations Teams prioritizing low integration effort and provider replaceability

This is a buy-versus-build decision, but “build” has two meanings. Building carrier delivery is a large on-call commitment. Building a thin policy and state layer is unavoidable because no vendor knows the marketplace's account risk, allowed countries, or session rules.

Layer Buy Build Reason
SMS delivery and code checking Yes Rarely Avoid owning carrier connectivity and its operational surface
Challenge state and resend admission No Yes The rule depends on product abuse risk and concurrency
Country allowlist and routing policy No Yes Compliance needs differ by country and product
Provider adapter Keep thin Yes A narrow contract contains migration effort

The capacity-planning reflex here is to count failure amplification, not merely normal traffic. A provider or carrier slowdown causes more user resends, more status polls, and more support contacts. A strict server-side budget limits all three; a client countdown limits none of them.

The trade-off is explicit: the unified REST option is not a fit when the workflow requires push webhooks, managed email OTP fallback, voice, WhatsApp, or RCS. Choose Twilio Verify or Vonage Verify when a dedicated verification product and its provider-specific workflow are the priority; choose AWS End User Messaging SMS when an existing AWS access and operations boundary matters more than a provider-neutral contract. The thin adapter is still useful in each case, but pretending every option has the same operational shape would make the comparison useless.

Verify and roll back without guessing

Before rollout, exercise simultaneous resend requests against shared state and prove that only one reservation wins. Test an allowed US destination, an allowed EU destination, and a denied country as separate policy cases. Then test expiry, the final permitted verification attempt, duplicate form submissions, a 429 with Retry-After, a non-2xx response body, and an observation deadline that ends in unknown rather than inventing delivery success.

Use separate SLO indicators for API acceptance, observed delivery state, code validation, and completed signup. This makes rollback criteria legible: if acceptance degrades after an adapter release, revert the adapter; if delivery changes for one route or country while acceptance remains stable, disable that route through application policy; if validation succeeds but signup completion drops, investigate the session boundary instead of switching SMS vendors.

A rollback must preserve outstanding challenges. Keep the provider identifier and adapter version with each challenge, so verification continues through the adapter that created it while new challenges move to the previous route. Do not migrate an in-flight challenge by sending another code unless the normal resend policy admits it.

Finally, run a controlled probe after each routing or adapter change: start one challenge, observe its status through bounded polling, verify it once, and confirm that replay cannot mint a second session. The probe is small on purpose. It checks the contract that users depend on without confusing a synthetic delivery with an end-to-end marketplace signup SLO.

References

Top comments (0)