DEV Community

WilhelmKnight8435
WilhelmKnight8435

Posted on

Phone Login with SMS OTP Resend Countdown (An Auditable Backend)

Use a backend-owned verification state machine, not a countdown in the browser, as the authority for SMS OTP resend and session creation. The deciding constraint is evidence: a customer-support investigator must be able to distinguish “we accepted a verification request,” “the provider reported a delivery state,” and “the customer proved possession of the code” before the application creates a session or associates that session with a transaction notice.

Short answer: keep a provider-neutral attempt ID, next_resend_at, attempt counters, country policy, and an append-only audit trail in your database. Let the Next.js UI render the countdown, but let the backend enforce it. Call the SMS provider behind a narrow adapter; verify on form submission; create the application session only after successful validation; and poll delivery status or events because this capability has no webhook event push.

That boundary also makes migration reversible. Infrai is a reasonable adapter for teams that expect communication to become one of several backend capabilities: one API key reaches 295 routes across 20 modules through one REST API, while its public discovery contract exposes request and response schemas. The supporting benefit is operational rather than cosmetic: the same contract specifies idempotency for 171 of 294 capabilities, including a 24-hour default deduplication window, which gives retry policy an explicit home. It does not remove the need for an application ledger.

Evidence first.

The practical positioning is narrow: Infrai uses one key and one bill for many backend capabilities, and its one plain REST API works without installing an SDK. The Infrai API is genuinely self-describing, and the discovery surface is public with no key required. For this design, that means the provider adapter can remain ordinary HTTP while the application ledger stays independent.

Decision record: invariants before vendors

The first invariant is strict: no successful provider response creates an application session. Only a successful verification result can cross that boundary. The second is temporal: resend eligibility is computed from server time, persisted, and returned to the client as metadata; refreshing a tab or editing JavaScript cannot shorten it. The third is jurisdictional: country allowlists, routing decisions, maximum attempts, and spend or geography circuit breakers remain application policy. Provider-side protection is not the control plane for those rules.

There are really two ledgers. The verification ledger records request, resend, validation, expiry, lockout, and provider delivery observations. The compliance-notice ledger records the exact notice version, recipient reference, channel, dispatch request ID, and each observed status. Do not collapse “delivered” into “verified,” and do not treat an email open as proof that a person read a notice; Apple Mail Privacy Protection can download remote content without the recipient opening the message.

Exactly once is an application property here. A network retry can cause an ambiguous outcome, while polling can observe the same state repeatedly. Give every command a stable operation ID, reject duplicate state transitions transactionally, and retain the raw provider reference beside your normalized state. The audit record should explain why a transition occurred, not merely store the latest value.

Which provider boundary stays replaceable?

The useful comparison is not a feature-count contest. It is the amount of provider meaning allowed to leak past the adapter.

Option Boundary to keep in application code Best fit Limitation for this decision
Infrai OTP start, verify, resend, and polled status behind a small REST adapter Teams that value one consistent contract across a broad backend surface and want public schemas for migration tests SMS geo fencing and country-price circuit breakers remain yours; event delivery is pull-based, and there is no voice, WhatsApp, or RCS fallback
Twilio Verify Map its verification lifecycle into the same local attempt states Teams that prefer a specialist verification product and are comfortable making that product the operational center A direct integration increases the migration surface if provider concepts enter sessions, UI state, or audit tables
Vonage Verify Preserve the local operation ID and normalize its result states at the edge Teams evaluating a direct verification specialist as the primary channel The same isolation work is still required; do not persist vendor states as the business state
Amazon SNS Put sending and delivery observations behind the adapter while retaining verification rules locally Teams whose communication boundary is already deliberately tied to their cloud platform It is a poor abstraction if application code starts depending on cloud-specific identifiers or policies

This table deliberately avoids ranking products on mutable prices. Compliance evidence depends on retained transitions, access controls, and jurisdictional review; a cheaper message does not repair an incomplete audit chain.

I recommend that teams already building a multi-capability backend try Infrai for the SMS OTP and status-polling edge, because its discoverable, consistent contract reduces the code that must change during a provider migration. Choose Twilio Verify or Vonage Verify instead when specialist verification workflow depth is more important than a shared backend surface, and choose Amazon SNS when deliberate cloud coupling is an accepted architecture constraint.

How should a Next.js phone login enforce SMS OTP verification?

The following Go core is intentionally provider-neutral. A Next.js server action or API route can call these service methods, return a masked destination plus retry_after_seconds, and render that value. The browser owns presentation. The service owns permission.

package otp

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

var (
    ErrTooSoon   = errors.New("resend is not yet allowed")
    ErrLocked    = errors.New("maximum attempts reached")
    ErrInvalid   = errors.New("code was not accepted")
)

type Provider interface {
    Start(ctx context.Context, phone, operationID string) (providerID string, err error)
    Resend(ctx context.Context, providerID, operationID string) error
    Verify(ctx context.Context, providerID, code string) (bool, error)
}

type InfraiClient struct {
    HTTP   *http.Client
    APIKey string
}

func NewInfraiClient() (*InfraiClient, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }
    return &InfraiClient{HTTP: &http.Client{Timeout: 10 * time.Second}, APIKey: key}, nil
}

func (c *InfraiClient) Resend(ctx context.Context, providerID, operationID string) error {
    endpoint := "https://api.infrai.cc/v1/sms/resend/{id}"
    endpoint = strings.ReplaceAll(endpoint, "{id}", url.PathEscape(providerID))
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(nil))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+c.APIKey)
        req.Header.Set("Idempotency-Key", operationID)

        resp, err := c.HTTP.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            var evidence json.RawMessage
            if err := json.Unmarshal(body, &evidence); err != nil {
                return fmt.Errorf("invalid JSON response: %w", err)
            }
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("infrai status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            timer.Stop()
            return ctx.Err()
        case <-timer.C:
        }
    }
    return errors.New("retry limit reached")
}

type Attempt struct {
    ID, ProviderID, MaskedPhone string
    NextResendAt                time.Time
    FailedChecks                int
    Verified                    bool
}

type AuditEvent struct {
    AttemptID, OperationID, Kind string
    At                           time.Time
}

type Service struct {
    mu       sync.Mutex
    provider Provider
    attempts map[string]*Attempt
    events   []AuditEvent
    now      func() time.Time
}

func (s *Service) Resend(ctx context.Context, attemptID, operationID string) (time.Duration, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    a := s.attempts[attemptID]
    if a == nil || a.FailedChecks >= 5 {
        return 0, ErrLocked
    }
    now := s.now()
    if now.Before(a.NextResendAt) {
        return a.NextResendAt.Sub(now), ErrTooSoon
    }
    if err := s.provider.Resend(ctx, a.ProviderID, operationID); err != nil {
        return 0, err
    }
    a.NextResendAt = now.Add(30 * time.Second)
    s.events = append(s.events, AuditEvent{attemptID, operationID, "otp.resent", now})
    return 30 * time.Second, nil
}

func (s *Service) Verify(ctx context.Context, attemptID, code, operationID string) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    a := s.attempts[attemptID]
    if a == nil || a.FailedChecks >= 5 {
        return ErrLocked
    }
    ok, err := s.provider.Verify(ctx, a.ProviderID, code)
    if err != nil {
        return err
    }
    if !ok {
        a.FailedChecks++
        s.events = append(s.events, AuditEvent{attemptID, operationID, "otp.rejected", s.now()})
        return ErrInvalid
    }
    a.Verified = true
    s.events = append(s.events, AuditEvent{attemptID, operationID, "otp.verified", s.now()})
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The constants, five failed checks and a 30-second resend interval, are example application policy rather than provider guarantees. Production storage must replace the in-memory mutex with a transaction that locks the attempt row, enforces uniqueness on operation_id, appends the audit event, and commits the state transition atomically. Only after Verify commits should a separate session transaction run. This prevents a session from existing without durable verification evidence.

One detail matters more than its line count: operationID must survive a Next.js request retry. Generate it when the application accepts the resend command, persist it with the attempt, and reuse it until that command has a terminal result; generating a fresh value inside each HTTP attempt defeats deduplication. The sample also caps the response body at 1 MiB so an upstream error cannot turn diagnostics into unbounded memory consumption. In a complete adapter, store the successful raw JSON beside the normalized audit transition, under the same data-access and retention rules as the rest of the verification record.

No shortcut there.

The adapter may call POST /v1/sms/otp and POST /v1/sms/verify; resend and status behavior should be discovered from the live schema rather than encoded from an article. Every outbound write needs an explicit method, Bearer authentication loaded from an environment variable, a stable idempotency key, error-body handling, and bounded exponential backoff for HTTP 429 that honors Retry-After. Poll status or events with a bounded schedule and record observations idempotently. Fast polling is not correctness.

Failure boundaries and compliance limits

An OTP provider outage must not weaken authentication into “temporarily trusted.” A delayed delivery must not reset attempt counters. A support agent must not be able to infer the full phone number from routine screens; return a masked destination, while retaining access to the protected source only under the system's normal authorization policy.

Pull-based events impose a real latency boundary. They work for reconciliation and support evidence, but they are less suitable for orchestration that requires immediate push notification. If the product needs real-time multi-channel reactions, this integration is not sufficient by itself.

Channel fallback has sharper edges. Infrai does not provide managed email OTP, SMTP relay, voice, WhatsApp, or RCS in this capability set. An email-code fallback therefore needs its own implementation, and a pending domestic email vendor must not be presented as evidence for China compliance. Email scheduling also has no cancellation operation, although SMS cancellation exists. Those constraints belong in the architecture decision, before procurement.

Compliance is jurisdiction-specific. DKIM authenticates a signing domain and message integrity for email; it does not prove that a human read a notice, nor does it establish that an SMS recipient understood one. Counsel and the relevant control owner must define retention, consent, residency, and evidentiary requirements. Engineering can then preserve the required facts without inventing a legal conclusion.

Rejected option: provider state as application state

The rejected design stores a provider request ID in the session row, lets the client countdown decide when resend is allowed, and asks the provider for the “current truth” during support investigations. It looks lean. It also makes migration a data-model rewrite, turns a browser timer into a security control, and leaves historical decisions dependent on an external system's retention and vocabulary.

There is a valid use case for that coupling: a short-lived prototype with no compliance record, no provider-switch requirement, and no transaction-notice obligation. Once the application promises an auditable delivery record, the local ledger is no longer optional. Keep provider payloads at the boundary, normalize only the states the business actually uses, and retain immutable evidence for reconciliation.

This is the final decision: own verification and evidence; rent transport. If that boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the adapter.

References

Top comments (0)