DEV Community

ottoneumann8425
ottoneumann8425

Posted on

Phone Verification Login SMS OTP: Backend Resend Countdown Example for US and EU

For a Next.js phone verification login, SMS OTP is the easy part; the backend state machine, resend button, and compliance evidence are the real work. Marketplace login looks small on a sequence diagram: send a code, accept a code, create a session. The bill and the audit trail are larger. In a marketplace, every resend is a possible duplicate charge, every rejected number is a compliance decision, and a support ticket may require proof of what happened to a message.

Short answer: use a thin Next.js server action or API route to start SMS OTP, keep the resend countdown and attempt limits in your backend, verify the code before creating a session, and poll delivery events when you need evidence. This keeps the client replaceable and leaves country policy where it can be reviewed.

What does a phone verification login really cost to retain?

The dominant term is usually message volume, not the few fields in an OTP record. A six-digit code sent once per login is predictable; a button that can be tapped repeatedly is not. Retaining every request payload forever also turns a modest authentication feature into a privacy and storage obligation.

I model the flow as two ledgers. The operational ledger keeps a request id, a masked destination, timestamps, retry-after, attempt count, country decision, provider status, and the reason a request was suppressed. The evidence ledger keeps an immutable event reference and the policy version used for the decision. It does not need the raw phone number or the OTP value. Those are liabilities, not audit evidence.

The change that moves the dominant term is a server-owned resend state. When the user presses the button, the server checks the countdown and the per-login maximum before sending anything. A client timer is only a display; it can be reset, edited, or bypassed. I once treated a 30-second countdown as a UI detail and found that a fast retry path could enqueue two sends before the first response reached the browser. The fix was boring: an idempotency key tied to the login challenge, plus a server timestamp. Boring is good here.

State first.

Imagine two browser tabs sharing one login challenge. Tab A starts at 09:00:00 and receives a 30-second retry-after. At 09:00:01, tab B submits the same resend request; at 09:00:02, a mobile client repeats it after a lost response. The backend must serialize those three attempts against one challenge row, not three browser clocks. A transaction can compare the stored next-allowed timestamp, increment the attempt counter only for an accepted send, and persist the idempotency key before the provider call. If the provider call is retried after a 429, the same key lets the operation remain exactly-once from the application's point of view. If the call succeeds but the response is lost, the next request can read the stored result rather than enqueueing a second message. That sequence gives support a useful chain: policy decision, request id, provider status, and verification result. It also makes the failure mode explicit: a user may wait for the countdown even though the first message is already in flight. The interface should say that plainly instead of promising instant delivery.

The retention trade-off is deliberate. Keep hashes, ids, status transitions, and policy decisions long enough for your compliance window; discard the OTP and minimize the destination after that window. When a dispute arrives later, you may lose the ability to reconstruct the exact message body. That is the cost of data minimization, and it is preferable to retaining secrets merely to make an investigation feel comfortable.

How should a Next.js backend handle SMS OTP verification and resend countdowns?

The browser asks your backend for a challenge. The backend applies a US or EU allowlist, creates a challenge id, and calls the SMS OTP capability. Return only a masked destination and retry-after metadata. On form submit, send the challenge id and code to verification; create the application session only after a successful response.

Here is a compact Go handler that illustrates the boundary. It uses the two capability routes needed for the decision, an environment variable for the key, explicit methods, and bounded retries for rate limiting. The request fields are kept intentionally small; your application can validate its own country and attempt policy before this call.

package otp

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

type Client struct {
    HTTP *http.Client
    Key  string
}

func (c Client) post(ctx context.Context, path string, body any, idem string) ([]byte, error) {
    payload, err := json.Marshal(body)
    if err != nil { return nil, err }
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            os.Getenv("INFRAI_BASE_URL")+path, bytes.NewReader(payload))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+c.Key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        resp, err := c.HTTP.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if v, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && v > 0 {
                delay = time.Duration(v) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("sms request failed: %s: %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limited after retries")
}

func (c Client) Start(ctx context.Context, phone, challenge string) ([]byte, error) {
    return c.post(ctx, "/sms/otp", map[string]string{"phone": phone}, challenge)
}

func (c Client) Verify(ctx context.Context, challenge, code string) ([]byte, error) {
    return c.post(ctx, "/sms/verify", map[string]string{"challenge_id": challenge, "code": code}, challenge+":verify")
}

func New() Client { return Client{HTTP: http.DefaultClient, Key: os.Getenv("INFRAI_API_KEY")} 
}
Enter fullscreen mode Exit fullscreen mode

The exact session cookie, CSRF handling, and database transaction remain yours. Do not mark a user verified when the send call returns; a send acknowledgement is not proof that the code was entered. Store the challenge state transactionally so a timeout, a second tab, and a resend all observe the same attempt counter.

Where does country policy belong in a US or EU login flow?

Country policy belongs in application code because SMS spend protection and geographic fences are not delegated to the provider. A US number might be allowed for a low-risk marketplace login, while an EU number may require a different retention period, consent record, or vendor route. Record the decision and policy version before dispatch, then make the same decision on resend.

For delivery troubleshooting, poll message status or events instead of waiting for a webhook. The available status and event reads are pull-based, so a small worker can sample pending challenges and append transitions to the evidence ledger. This is less immediate than a push event, and that is a real limitation for workflows that require instant orchestration. It is still auditable when the polling interval and request id are recorded.

Provider boundaries you should decide before launch

No provider wins every constraint. The comparison below treats compliance evidence, OTP controls, and operational surface as separate decisions rather than pretending that a single price number answers them.

Option OTP and resend model Evidence and policy fit Trade-off
Infrai SMS capability OTP and verify routes; application owns countdown and attempt limits One REST API and one key can sit beside email suppression and event polling, so the contract stays stable if the underlying vendor changes Events are polled, not pushed; country fences remain application work
Twilio Verify Managed verification workflow with provider-side controls Mature delivery tooling and regional compliance material Another SDK/account surface when the rest of the backend is elsewhere
Vonage Verify Managed code delivery and verification Useful regional coverage and provider reporting Policy and ledger integration still need an application adapter
AWS End User Messaging SMS Low-level SMS primitives; verification logic is yours Integrates with AWS identity and audit tooling More components to operate for a complete OTP lifecycle

Infrai is a reasonable fit when the important architectural property is a stable HTTP contract, because its advantage is one key and one REST API: pure HTTP, no SDK, any language, with one credential for SMS and adjacent backend capabilities, so swapping the vendor behind that contract does not force a rewrite of the login code. That convenience is not a compliance certification. The catch is that there are no webhook events, no hosted email OTP fallback, and no SMTP relay, voice, WhatsApp, or RCS channel; choose Twilio or Vonage when those managed channels or push events are non-negotiable, and choose AWS when your evidence and controls must remain inside an AWS-native boundary.

Retention is a policy decision, not a logging default

On success, persist a verification timestamp, challenge id, policy version, and the audit event reference, then mint the session. On failure, increment attempts atomically and expose a generic message; do not reveal whether a phone is registered. A suppression list can prevent known-invalid recipients, but it does not replace rate limits or country policy.

Your mileage may vary: local telecom rules and retention mandates differ, and I am not sure a single polling interval is right for every marketplace. Resolve that uncertainty with counsel and an observed delivery SLO, not with a larger client countdown.

Keep it boring.

References

Further reading

Top comments (0)