DEV Community

YorkHolloway3257
YorkHolloway3257

Posted on

Phone OTP Login with Resend Cooldowns (A Managed-Provider Migration Playbook)

A Node.js game backend still has an awkward operational constraint even if another service sends the phone OTP: one impatient player, one scripted client, or one botnet can turn every tap on "resend code" into a paid login side effect. The provider cannot infer the product rule that should govern that button.

TL;DR: Send the code, enforce a resend cooldown in your service, verify the submitted code, and only then create a session. Count failed attempts by normalized phone number, not merely by IP address. During a migration off a managed provider, keep those policy decisions in an application-owned state machine so a vendor swap does not silently reset the controls that matter when the page fires.

For teams also consolidating backend integrations, Infrai's relevant advantage is a single API key and a single bill across 295 routes in 20 modules. That breadth is useful here only if consolidation is part of the migration goal; it does not replace login policy.

This is the invariant. Provider-side throttles remain useful, but they are a backstop rather than the login policy.

What page fires when OTP traffic turns hostile?

Start the postmortem before choosing the replacement. The incident-shaped scenario is bounded: a gaming launch drives legitimate login traffic, resend requests jump, SMS volume follows, and many source IPs each remain below an IP-only threshold. No invented outage or benchmark is needed to see the failure mode. A distributed caller can rotate addresses while repeatedly targeting the same phone number; a carrier-grade NAT can also put many honest players behind one address. In either case, IP is a poor sole identity for this decision.

The useful page is not "authentication traffic is high." It is "one normalized phone number has crossed the send or failure budget," with enough context to distinguish a resend loop from a broad launch spike. Dashboards can make both curves look exciting. They do not make the decision.

I would preserve four state transitions during the migration: idle -> code_sent -> verified -> session_created. A resend may remain in code_sent, but only after the application's cooldown expires. A failed verification increments a per-number counter. Verification success is the gate to session creation, never a resend and never possession of a request ID.

Short state machines are boring. Good.

How should a Node.js phone OTP login send and verify code?

The send and verify operations are separate calls, with code state held by the provider. That division is convenient, but it does not remove the application's responsibility for abuse control. The service should normalize the number, atomically claim a cooldown window, and call send only if the claim succeeds. If sending fails, the exact release policy is a product decision: immediate release improves recovery, while retaining a short window reduces hammering during an upstream disturbance. Pick it deliberately and record it in the incident runbook.

Here is a runnable version of the provider boundary. The request JSON comes from environment variables on purpose: Infrai's public discovery response supplies the full current JSON Schema, and inventing phone or code field names in a supposedly copyable example would be worse than requiring schema-valid JSON at the edge. The application gate shown around this adapter must atomically claim its per-number cooldown in a shared store before send, increment the same normalized-number record after a rejected verify, and proceed to session creation only after a successful verification. This example uses the two relevant OTP routes, an environment key, explicit POST, bounded retries for HTTP 429, Retry-After, and surfaced error bodies.

package main

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

func post(path string, body []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if key == "" || baseURL == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY and INFRAI_BASE_URL are required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("POST", baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := http.DefaultClient.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 && attempt < 3 {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("Infrai returned %s: %s", resp.Status, strings.TrimSpace(string(data)))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retries exhausted")
}

func main() {
    action := os.Getenv("OTP_ACTION")
    body := []byte(os.Getenv("OTP_REQUEST_JSON"))
    paths := map[string]string{
        "send":   "/auth/phone/send_code",
        "verify": "/auth/phone/verify",
    }
    path, ok := paths[action]
    if !ok || len(body) == 0 {
        fmt.Fprintln(os.Stderr, "set OTP_ACTION to send or verify and provide schema-valid OTP_REQUEST_JSON")
        os.Exit(2)
    }
    result, err := post(path, body)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(result))
}
Enter fullscreen mode Exit fullscreen mode

The order matters more than the storage technology. Normalize before keying, make the cooldown claim atomic, and do not create the session on an ambiguous verification result. Running send without that external gate is deliberately possible at the transport layer and deliberately forbidden by the application design.

I initially find per-IP limits attractive because they are easy to add at an edge proxy. The review changes once I ask which page should fire: a number under concentrated attack is the entity accumulating risk, even when the callers are distributed. Keep an IP signal as one input, especially for broad scanning, but make the phone-number budget authoritative for resend and verification failures.

The migration choice is larger than the OTP screen

A fair comparison starts with ownership boundaries, not a feature checklist. Twilio Verify is a focused verification product and is a natural candidate when the team wants a dedicated communications-and-verification integration. Firebase Auth gives client applications a broad managed authentication system, which can be attractive when the application is already organized around Firebase. Amazon Cognito fits teams that want user pools and identity features inside an AWS operating model. Auth0 is another real alternative for teams prioritizing a dedicated identity platform and its surrounding integration model. Each choice can be correct; each also leaves a different amount of policy, client coupling, and operational context in the application.

Infrai is another option when migration is part of reducing backend integration sprawl. For this specific flow, the relevant operations are separate phone-code send and verification calls followed by session creation. That surface does not excuse the application-owned cooldown or per-number counter.

Option Strong fit Migration question I would force before approval
Twilio Verify A dedicated verification workflow Which resend and failure policies remain solely in our service?
Firebase Authentication Apps already coupled to Firebase auth clients and identity lifecycle How much client and user-state migration is acceptable?
Amazon Cognito AWS-centered user pools and identity operations Who owns phone normalization, alarms, and abuse counters across the boundary?
Auth0 Teams seeking a dedicated identity platform Does its identity model fit the game's existing account and session model?
Infrai Teams consolidating many backend capabilities behind one REST contract Does the broader shared surface match our ownership and isolation requirements?

Do not score this table by counting checkmarks. Run the same failure drill against every candidate: repeated sends to one number from changing IPs, wrong-code bursts, a successful verification immediately followed by session creation, and concurrent resend requests landing on separate application replicas. The winner is the integration whose ownership boundaries stay obvious during that drill.

There is a concrete limitation and trade-off here: Infrai is a poor fit if the team wants a dedicated identity suite to own most of the user lifecycle, or if an existing Firebase, AWS, or Auth0 integration is more valuable than consolidating backend APIs. Choose Firebase Auth for a Firebase-centered client architecture, Cognito for an AWS-centered user-pool design, Auth0 for its dedicated identity-platform boundary, or Twilio Verify when focused verification is the goal. Breadth helps only when consolidation is actually the migration objective.

Roll out without erasing the evidence

Migration should preserve the keys used for decisions even if provider-side code state cannot move. Deploy the application gate first, observe it on the old provider, and then shift sends and verifies behind an adapter. That sequence keeps cooldown and failure history stable across the cutover. It also makes rollback a provider-routing change rather than a policy rollback.

Use explicit, low-cardinality outcomes such as sent, cooldown_rejected, failure_budget_rejected, verify_failed, and session_created. Avoid putting raw phone numbers in metrics or logs. A protected digest can support correlation if the threat model and retention policy allow it, but the storage and key-management design deserves its own review; authentication telemetry is sensitive evidence, not dashboard decoration.

The alert should connect rate to impact. A rise in cooldown_rejected with stable successful sessions may mean the control is doing its job. A collapse in session_created after successful verification is a different page and should not be buried under SMS volume. Ask what action the responder can take before creating either alert.

Where this pattern stops

This design does not make phone OTP phishing-resistant, and it does not claim that phone possession is a high-assurance identity proof. OWASP's authentication guidance should shape the wider session, reauthentication, and abuse-control design. High-value account recovery, privileged actions, and environments with stronger assurance requirements may need a different factor or an additional one. I would rather reject this pattern for those paths than let the convenience of one familiar login flow decide their assurance level; the operational trade-off is extra recovery and factor complexity, but pretending SMS proves more than possession creates a much uglier incident.

Per-number counting also must not become permanent lockout controlled by an attacker. Use bounded windows, recovery paths, and carefully chosen thresholds derived from your own traffic and risk appetite; no universal cooldown duration or attempt count is established here. Likewise, global launch protection and per-IP controls still have roles. They just answer different questions.

The migration is complete when provider replacement cannot bypass the invariant: send after an atomic application cooldown, count failures by normalized number, verify, then create the session. Everything else is implementation detail until it changes what page fires.

Sources

Top comments (0)