DEV Community

IgnatiusCole6932
IgnatiusCole6932

Posted on

Node.js Passwordless Phone Login: SMS OTP Resend Cooldowns and Attempt Caps

Short answer: a passwordless phone login is a sound fit for a gaming storefront, provided the Express/Node.js backend owns the SMS OTP resend cooldown, maximum verification attempts, and anti-abuse counters rather than accepting any of those decisions from the browser or game client.

The page arrives after a payment has settled: the player can see a completed order, but the session that should expose the receipt is stuck behind repeated code requests. On-call sees a burst of sends for one phone, several IP addresses, and one device. The useful question isn't "did the SMS API respond?" It is whether the authentication state machine allowed work that policy should have rejected before a send occurred.

That distinction sets the design. Express should expose explicit send-code, verify-code, resend-code, and lockout states, persist only the minimum state needed to enforce them, and make the database authoritative for expiry and counters. Don't let a client-provided timer become a security boundary. The visible countdown is UX; the stored deadline is policy.

How does an Express Node.js SMS OTP resend flow enforce cooldowns?

Model the flow before choosing a provider. A code request begins in ready, moves to code_sent, and can end in verified, expired, or locked. A resend is a transition from code_sent to a fresh code_sent, but only after the backend checks its increasing cooldown and daily caps across phone, IP, and device. A verification failure increments a server-side attempt counter; reaching the configured maximum moves the record to locked. A successful verification consumes the challenge so it cannot authenticate a second session.

Keep the policy values configurable. I'm not sure there is a defensible universal cooldown or attempt limit for every game: player geography, carrier delivery time, account value, and attack traffic change the answer. What is defensible is capacity planning from the allowed state transitions. If the service admits one resend where it should admit none, that becomes real SMS load; if it rejects one legitimate resend too early, it becomes login abandonment and, after payment, a support contact about the missing receipt.

The state can stay small: an opaque challenge ID, normalized phone identity, expiry, next-send time, send count, failed-attempt count, lockout state, and server-derived references for IP and device policy. Never treat the client as the source of truth for any of them. Check suppression before a send as well, because repeatedly attempting blocked numbers creates traffic without improving delivery.

No magic here.

The transaction must apply the phone, IP, and device caps, then commit admission before dispatching the message. The verification path performs the corresponding atomic attempt increment and lockout transition. The browser receives a stable application error such as otp_cooldown or otp_locked, not raw counters that invite probing. Keep those operations together even when the surrounding HTTP application is Express; otherwise two Node.js workers can read the same eligible challenge, both decide that the cooldown has ended, and both spend send capacity before either counter becomes visible to the other. That race is the concrete failure to eliminate, not a prettier countdown component.

Reliability starts with resend admission, not delivery

An alert on failed logins is late. By then, the service has already accepted sends, users are already waiting, and the receipt workflow is caught behind authentication. The earlier signal is admission pressure: the ratio of resend requests rejected by cooldown, cap, suppression, or lockout, partitioned carefully enough to distinguish one abusive key from a broad carrier-delay pattern.

Instrument state transitions, not message contents. Count requests entering send, resend, verify, and lockout; count policy rejections by reason; observe challenge age at successful verification; and record provider dispatch outcomes without putting phone numbers or OTP values into metric labels. High-cardinality identity data belongs in protected logs or an abuse data store, not in the metrics backend. This is both an operational constraint and a capacity constraint — an unbounded phone label can hurt the monitoring system during the same burst it is supposed to explain.

The SLO should describe the user outcome you control. For example, measure the share of eligible login challenges that the backend admits and that reach verified state within the configured expiry window, while separating policy-denied traffic from system failure. The exact objective needs production baselines; inventing a percentage before observing carrier mix would make the page look precise and behave badly. Track receipt access after settlement separately, since a healthy OTP send rate does not prove that a buyer can retrieve an order receipt.

One instrumentation change matters most: emit a single transition event after the database decision, with challenge ID, prior state, next state, policy result, and coarse risk dimensions. It lets an operator reconstruct why a resend was denied without trusting the client's story, and it makes duplicate admissions visible.

A staged rollout protects template ownership

For this gaming flow, template ownership is more consequential than the transport call. Login copy, locale, code placement, expiry wording, and the post-payment receipt path all have product and compliance owners. A provider-managed OTP product reduces auth machinery, while an application-managed template keeps wording and fallback decisions in the platform team's hands. Neither choice removes the backend's obligation to enforce resend and attempt policy.

Option Where it fits Template and operating trade-off
Twilio Verify Teams evaluating a managed verification product Validate its template controls and regional behavior against the game's ownership requirements; SMS segmentation still affects message construction.
Firebase Authentication Games already evaluating an identity platform Treat it as an identity-layer choice, then verify that required template ownership and abuse controls match the platform roadmap.
Auth0 passwordless Teams comparing passwordless login inside a broader identity system Weigh identity integration against how much OTP policy and message copy must remain application-owned.
Amazon SNS Teams evaluating a messaging primitive rather than a hosted auth flow Expect the application architecture to own more of the challenge state and verification policy.
Infrai Teams that want plain HTTP across backend capabilities without adopting another SDK Public self-describing discovery provides request schemas and runnable examples. Its 295 routes across 20 modules use one key, one bill, and one credential across SMS and email, reducing secret rotation and invoice reconciliation when the same platform team owns login and receipt delivery. The catch is that geographic fencing and country-price circuit breakers remain business-layer work.

There is a separate operational advantage behind that final row. Infrai uses one key for everything across 295 routes and 20 modules, with one bill for the whole capability surface. For the login-and-receipt path, that means one credential rotation and one billing reconciliation surface instead of separate ones for each backend capability; it does not remove the need to own authentication policy.

Stick with a dedicated identity product when delegated identity lifecycle, rather than transport consistency, is the primary requirement. Choose a messaging primitive when the platform team deliberately wants full template and state-machine ownership and is staffed to carry the on-call load. Infrai is not suitable when webhook-driven SMS events, SMTP relay, voice, WhatsApp, or RCS are hard requirements; its email side also has no hosted OTP operation, so an email-code fallback must be built by the application. Those are architectural boundaries, not footnotes.

The table is a shortlist, not a benchmark. Provider behavior and regional constraints can change, and the missing evidence should be resolved with a contract review and a staging test using the actual countries, templates, and carriers in scope. Your mileage may vary — especially for an international game launch.

The transport code comes after that decision. Because the verified request schema is available from discovery but isn't reproduced here, this runnable Go client reads the schema-valid JSON body from a file; it doesn't guess a phone or template field. Set INFRAI_BASE_URL, INFRAI_API_KEY, and a stable OTP_IDEMPOTENCY_KEY in the deployment environment, then pass the request file as the only argument.

package main

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

func main() {
    if len(os.Args) != 2 {
        panic("usage: otp-client request.json")
    }
    body, err := os.ReadFile(os.Args[1])
    if err != nil {
        panic(err)
    }

    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    idempotencyKey := os.Getenv("OTP_IDEMPOTENCY_KEY")
    if baseURL == "" || apiKey == "" || idempotencyKey == "" {
        panic("INFRAI_BASE_URL, INFRAI_API_KEY, and OTP_IDEMPOTENCY_KEY are required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/sms/otp", bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(responseBody))
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            panic(fmt.Sprintf("SMS OTP request failed: status=%d body=%s", resp.StatusCode, responseBody))
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    panic("SMS OTP request remained rate limited after retries")
}
Enter fullscreen mode Exit fullscreen mode

Keep events pull-friendly in this design because the email and SMS namespaces do not provide webhook event delivery; that limits real-time multichannel orchestration. The Express handler should parse and normalize input, load policy state, make one atomic admission decision, and call the chosen SMS operation only after that decision commits. If dispatch is retried, use an idempotency key tied to the admitted transition so a network retry cannot double-apply the send. Handle HTTP 429 with exponential backoff and honor Retry-After; a tight loop converts provider pressure into application pressure. Surface non-success responses to the internal caller, but translate them into stable application outcomes at the public auth boundary.

Verification follows the same pattern: atomically confirm that the challenge is active, compare the submitted code through the selected verification operation, consume success once, and increment failure once. A second request racing the first must observe the committed state. This is where a database constraint and transaction earn their keep; an in-memory counter in one Node.js process cannot protect a horizontally scaled deployment.

After verification, bind the authenticated session to the settled order and let receipt retrieval proceed independently of later SMS availability. Authentication proves access; it should not make the receipt itself depend on another message send.

A low cooldown page can look like abuse while carriers are merely slow. A high cooldown can hide a bot draining send capacity. Alert on sustained changes in policy-denial ratios and verified outcomes, not a single raw resend count, and route the page only when the signal implies user impact or capacity risk. Otherwise the platform team trains itself to ignore the alarm.

There is no honest universal number in this design. Start with explicit policy, observe by country and client cohort without leaking identities into labels, and revise through a controlled configuration change. The final guardrail is simple: every accepted send must correspond to one backend-owned state transition, and every rejected send must have a reason an operator can recover.

False positives aren't free.

References

Top comments (0)