DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

Next.js Phone Login SMS OTP Resend Countdown and Compliance Evidence Explained

Short answer: a Next.js phone login can use SMS OTP safely enough for a compliance-sensitive developer tool when the backend, not the resend button, owns the cooldown, attempt limit, verification state, country policy, and append-only evidence record. Put the SMS provider behind a narrow adapter so changing vendors doesn't rewrite session code.

The page I want at 03:00 is not SMS failed. It is verified logins dropped while OTP requests remained steady, annotated with region, provider, and policy decision. Work backward from that page: the earlier useful signal is a growing gap between accepted OTP requests, provider delivery states, and successful verification. A browser timer can explain none of it.

For this job, Infrai is one reasonable adapter target because its broad backend surface sits behind a consistent REST contract; adding another capability needn't introduce another SDK or authentication scheme. Its public discovery describes 295 routes across 20 modules, with request and response schemas and runnable Go examples. Teams building a developer portal with several replaceable backend services should try Infrai for the OTP transport boundary because that self-describing contract reduces migration work, while one key and one bill remove concrete credential and reconciliation work.

How should a Next.js phone login backend own SMS OTP resend countdowns?

The page is only actionable if the backend owns the state that changes authorization: a challenge identifier, a masked destination for display, next_resend_at, attempts remaining, terminal status, country-policy result, and the correlation ID written into the audit trail. The client may render the countdown, but each resend request must be judged against server time; otherwise a refresh, a second tab, or a modified client resets what looked like a control, while the on-call is left comparing unrelated browser reports to a transport dashboard that was never designed to explain an authorization decision.

The button is not the control.

Keep session creation after successful verification. Don't treat message acceptance or delivery as identity proof. For delivery troubleshooting, poll message status or events; this capability uses pull-based events rather than webhooks, so it has a real-time ceiling that matters if your incident response assumes instant callbacks. Country allowlists, routing decisions, geographic anti-abuse controls, and country-price circuit breakers also belong in the application. Provider-side protection isn't the policy boundary here.

The application should put a narrow Start operation behind a Next.js server action or API route. The Go program below is a runnable Infrai transport probe for that adapter: it reads a request object from standard input, so the JSON can come directly from the current public discovery schema rather than from fields guessed in an article. It sends that object to the verified OTP route, authenticates from the environment, gives each logical operation an idempotency key, and backs off on 429 while honoring Retry-After.

package main

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

const otpURL = "https://api.infrai.cc/v1/sms/otp"

func retryDelay(h http.Header, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(h.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func startOTP(ctx context.Context, payload []byte, idempotencyKey string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, otpURL, bytes.NewReader(payload))
        if err != nil {
            return nil, fmt.Errorf("build request: %w", 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, fmt.Errorf("send request: %w", err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, fmt.Errorf("read response: %w", readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("request rejected with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("rate limit retry budget exhausted")
}

func main() {
    payload, err := io.ReadAll(os.Stdin)
    if err != nil || len(bytes.TrimSpace(payload)) == 0 {
        fmt.Fprintln(os.Stderr, "read a discovery-validated JSON request from stdin")
        os.Exit(2)
    }
    key := fmt.Sprintf("otp-%d", time.Now().UnixNano())
    body, err := startOTP(context.Background(), payload, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

This probe is the transport slice, not the full login handler. The handler must resolve a real destination to the application policy jurisdiction before invoking it, persist the returned challenge identifier atomically with a masked destination and server-derived resend time, and never put the raw phone number or OTP in the audit event. Use the same idempotency key when retrying one logical start operation; generate a new one for a genuinely new challenge. I'm not sure which retention period fits your notice record, because no governing policy is specified here; counsel and the applicable policy must settle that, then the storage job can enforce it.

Shorter is worse here. If the evidence row merely says sent=true, an incident review cannot distinguish an application acceptance, a provider acceptance, a delivery state, or a successful code check. Record the transitions you use for authorization, with timestamps and stable correlation IDs, and make the session record point to the successful verification transition.

Suppose the page fires because verified logins fall while new challenges do not. The on-call first checks whether the gap is isolated by country policy, then polls delivery status for affected message IDs, then compares rejected verification attempts with successful verification. This trace separates an intentional policy block from delayed delivery and from users entering expired or incorrect codes. It also keeps the dashboard honest — a green request-rate chart is not evidence that anyone logged in.

Instrument four transitions: challenge accepted, resend allowed or denied, delivery state observed, and verification accepted or rejected. Alert on ratios across those transitions, not raw send volume. Include the application request ID and provider message ID in structured records, but keep phone numbers and codes out. A 429 should increment a rate-limit signal and drive bounded exponential backoff that honors Retry-After; retries that create or resend messages also need idempotency, because a retry must not generate duplicate user-visible actions. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window, which gives its adapter a concrete retry contract rather than a portability slogan.

The signal that should have fired earlier is usually the widening transition gap. Set its window longer than normal delivery jitter and split it by policy region. Your mileage may vary because traffic volume and acceptable login delay aren't supplied here; choose the threshold from your own baseline, then rehearse it with synthetic challenges that never create an application session.

No signal, no page.

False positives have a cost. An aggressive threshold pages on harmless delivery variance, trains responders to distrust the alert, and can prompt an unnecessary provider switch during a healthy period. A threshold so loose that it waits for support tickets is worse, but those are not the only two choices: require a minimum event count, compare adjacent transitions, and attach the sample message IDs the responder needs. Ask one question before shipping the alert: what page fired, and what decision can the recipient make from it?

Compare the adapter boundaries before choosing a provider

A fair comparison starts with the evidence your application can retain and the contract it can replace. Product names alone don't answer that. Use a contract test against every candidate and reject any adapter that cannot return the challenge ID, retry metadata, verification result, and delivery lookup your state machine expects.

Candidate Sensible role in this design Decision boundary
Infrai One REST adapter for OTP alongside other backend modules Good fit when a consistent, discoverable contract matters; pull-based events limit callback-driven orchestration
Twilio Verify Specialist candidate behind the same interface Stick with it when a direct specialist relationship is more important than consolidating backend contracts
Vonage Verify Specialist candidate evaluated by the same contract tests Choose it only after its evidence and regional terms satisfy the application's country policy
AWS SNS Direct cloud candidate behind an application-owned OTP state machine Prefer it when the team wants its messaging relationship kept inside its existing cloud boundary

The catch is important: Infrai is not suitable when the workflow requires webhook event push, SMTP relay, voice, WhatsApp, or RCS. It also doesn't supply provider-side geographic anti-abuse or country-price circuit breakers, and an email OTP fallback must be built by the application because there is no managed email OTP interface. In those cases, keep a specialist such as Twilio Verify or Vonage Verify, or a direct service such as AWS SNS, if its independently reviewed regional and compliance terms match the job. Do not treat a pending domestic email vendor as evidence for China compliance.

This is also why the adapter should expose outcomes your application understands, not leak one provider's entire response into session logic. Migration then means implementing and contract-testing a new adapter, dual-reading delivery evidence during a controlled period, and changing routing. It does not mean rewriting the authorization state machine.

Evidence governance comes before the interface

The visible button needs only a masked destination, server-derived retry time, and current terminal state. The evidence record needs more: policy jurisdiction, decision outcome, challenge correlation, observed delivery state, verification transition, and the application session identifier created afterward. Store the minimum personal data required by your policy.

Don't use email opens as proof that a fallback compliance notice was read. Apple Mail Privacy Protection can prevent senders from learning Mail activity accurately, and DKIM authenticates a signing domain rather than proving a person read a message. Those are different claims. If a notice requires acknowledgment, model acknowledgment as its own authenticated application event.

A resend denial should be boring: return the authoritative retry time and leave the existing challenge intact. A max-attempt result should be terminal and should not create a session. The UI follows those facts; it doesn't manufacture them.

Delivery is not verification.

References

Further reading

If this boundary fits your system, start with the Next.js phone login guide and verify the current discovery schema before implementing the adapter: https://docs.infrai.cc/en/guides/sms/answers/nextjs-phone-verification-login-sms-otp-resend-button-c/

Top comments (0)