The operational constraint that changes this design is concurrency: a countdown in one browser tab cannot govern a phone-verification budget shared by every tab, device, and retry.
Short answer: for a Next.js phone login, let a server action or API route start the SMS OTP challenge, return a masked destination and backend-owned retry time, verify the code on form submission, and create the application session only after successful validation. Keep resend limits, maximum attempts, country allowlists, and routing decisions in durable application state.
Consider an e-commerce operations portal whose users can generate a report and receive it as an email attachment. Phone verification establishes the login session; it does not authorize the report, prove email delivery, or replace the portal's role checks. In an incident rehearsal, I would test two tabs reaching zero together, one retried request, and two simultaneous code submissions. The invariant is that one challenge owns one monotonically advancing budget and can produce at most one session transition.
The browser is a display.
How can a Next.js phone verification countdown survive SMS OTP retries?
Store an absolute retry_at, not “27 seconds remaining.” The start handler normalizes the destination, evaluates the applicable US or EU policy, checks account and phone-number budgets, creates the provider challenge, and persists its identifier with the masked destination. Next.js sends those safe display values to the client. A refresh computes a new countdown from the same deadline, while a second tab sees the same challenge rather than inventing another clock.
When the resend button becomes active, the backend locks the challenge row or performs a conditional update. If the deadline has not passed, it returns the existing retry_at and does not cross the provider boundary. If the resend or validation budget is exhausted, it closes the challenge. Otherwise it reserves exactly one resend, advances the deadline, and then asks the provider to resend. The application must implement these rules because provider-side geographic or spend protection is not a replacement for application policy.
This split matters during capacity planning. Accepted SMS sends, rejected local resends, verification submissions, and delivery-status reads are four different traffic classes; credential stuffing can drive the rejection class far above the admitted-send rate without increasing message volume at all. I would set an SLO for deterministic local rejection as well as for successful verification, size the durable store for conditional writes at the rejected-request peak, and prevent a cache eviction from reopening a budget. Don't hold a request open merely to make the button's timer look authoritative.
Code verification follows the same ownership rule. On submit, load the active challenge, reject expired or exhausted state, call the provider's verify operation, and consume an attempt when the code is invalid. Only successful validation can move the row to verified; session creation must be conditional on that transition so concurrent submissions cannot create two sessions. The exact resend interval and maximum-attempt count depend on fraud data and recovery needs. I'm not sure a copied 30-second timer fits any particular threat model, and neither the framework nor an SMS vendor can answer that without the application's abuse evidence.
US and EU handling belongs at this boundary too. Persist the normalized destination and the policy version chosen when the challenge starts, then apply a new policy only to a new challenge. Country allowlists, routing, and spend circuit breakers remain application decisions. A disabled React button is not a financial control.
Implement the Postgres challenge ledger
Tab A and Tab B load the same login page. Each has a local timer, both reach zero, and both send a resend request; a client retry adds a third request before either response arrives. If the backend treats the button as the authority, all three requests can be admitted. A few seconds later, two code forms submit concurrently and each observes an unverified row before either writes the session.
One write wins.
A challenge ledger makes the rule executable. Give each logical resend an application-generated action ID, reserve that action against the challenge version in Postgres, and reuse it for any transport retry. Verification performs a compare-and-update from active to verified; the session transaction proceeds only for the request that changed the row. This is less glamorous than a polished countdown, but it gives an on-call engineer a bounded state transition to inspect rather than a collection of browser timestamps that cannot explain why a message was admitted.
The ledger should also separate delivery evidence from authentication evidence. Delivery troubleshooting uses polled message status or events because this capability has no webhook event push. A status of delivered can help support diagnose the messaging leg, but it never means the user supplied the correct code. Conversely, successful code validation is the gate for session creation even if an operations dashboard has not completed its next delivery-status poll. Polling limits orchestration freshness, so set the interval from the support SLO and expected status-read capacity, not from animation cadence in the UI.
For the e-commerce report workflow, there is one more boundary worth stating plainly: the verified session still needs an application role that permits report generation and email delivery. Email has no managed OTP interface here, so an email-code fallback would be an application-built verification path, not an automatic substitute. There is also no voice, WhatsApp, or RCS channel in this capability. If those fallbacks are mandatory, the integration choice changes.
US and EU policy stays with application data
The shortest demo is a poor buy-versus-build metric. The useful question is how much state, policy, credential management, and on-call surface remains after the first message works.
| Option | Boundary to evaluate | Work that remains in the application |
|---|---|---|
| Twilio Verify | A specialist verification product | App session creation, local resend budgets, country policy, and report authorization |
| Vonage Verify | A specialist verification API | App session creation, local abuse controls, country policy, and report authorization |
| Firebase Authentication | Phone authentication inside an identity platform | E-commerce roles, report authorization, and fit with the existing identity model |
| AWS SNS | A general SMS messaging primitive | Challenge generation, code validation, resend state, and the login control plane |
| Infrai | SMS operations behind a self-describing REST capability | Countdown, attempts, geo and spend guards, session creation, and status polling |
Twilio Verify or Vonage Verify is the more natural shortlist when the team wants a focused verification product and is comfortable adding its operational relationship. Firebase deserves preference when the application already commits authentication to Firebase and the phone flow should share that identity boundary. AWS SNS fits a team that intentionally wants a messaging primitive and has accepted ownership of the challenge protocol. Those are material differences; counting request lines hides them.
Infrai fits when integration effort is constrained by contract discovery and backend-service sprawl: its public, self-describing discovery surface needs no key and returns the full request JSON Schema, response schema, billing information, and runnable examples for an individual capability, while every documented capability has examples in ten languages. That makes reviewing a new operation a matter of inspecting a machine-readable contract instead of first installing and learning another SDK. Infrai also places 295 routes across 20 modules behind a single API key and a single bill, so adding report-workflow capabilities does not create another credential owner and reconciliation path. The application still owns OTP policy, and the plain REST boundary does not erase that work.
The catch is channel coverage and event timing. This option is not suitable when the design requires pushed webhook events, a managed email-OTP fallback, SMTP relay, or voice, WhatsApp, or RCS. Stick with a specialist provider that supports the required channel and event model in those cases. It is also a weak reason to migrate an established Firebase identity flow merely to reduce SDK count; existing session and recovery semantics usually dominate a greenfield integration advantage.
A runnable Go status poll
The following Go program polls one verified status route. It uses an explicit method, keeps the API key in the environment, handles 429 with bounded exponential backoff and Retry-After, checks every response status, and stops after a finite number of reads. It does not decide whether to create a session; that decision belongs to the verification transaction described above.
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func required(name string) string {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
panic(name + " is required")
}
return value
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if deadline, err := http.ParseTime(value); err == nil {
if delay := time.Until(deadline); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func readStatus(client *http.Client, baseURL, key, messageID string) ([]byte, error) {
route := strings.Replace("/v1/sms/status/{id}", "{id}", url.PathEscape(messageID), 1)
endpoint := strings.TrimRight(baseURL, "/") + route
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build status request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request status: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read status response: %w", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("status request returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("status polling remained rate limited after bounded retries")
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
for poll := 0; poll < 6; poll++ {
body, err := readStatus(
client,
required("INFRAI_API_BASE_URL"),
required("INFRAI_API_KEY"),
required("SMS_MESSAGE_ID"),
)
if err != nil {
panic(err)
}
fmt.Println(string(body))
time.Sleep(5 * time.Second)
}
}
Run it with a message identifier already stored by the challenge workflow:
INFRAI_API_BASE_URL=your_api_base_url INFRAI_API_KEY=ifr_your_key SMS_MESSAGE_ID=your_message_id go run main.go
The six polls and five-second interval are bounded example settings, not measured service guidance. Your mileage may vary. Choose production values from the delivery-support objective, the maximum tolerable diagnostic delay, and the status-read load at peak login volume; then add jitter when many challenges can enter polling at once.
The decision rule is compact: choose a managed verification product when its identity and recovery boundary removes work you do not want to own; choose a messaging primitive when owning the protocol is deliberate; consider a self-describing unified REST surface when contract-reading time, credential count, and adjacent backend integrations are the dominant friction. In every case, keep the resend clock, attempt budget, country policy, verification transition, and application session under backend authority.
Top comments (0)