DEV Community

FairchildBlake8483
FairchildBlake8483

Posted on

Node.js SMS OTP APIs: 6 Controls for US/EU SaaS Login

Short answer: For a logistics SaaS sending short-expiry password-reset codes in the US and EU, use a hosted SMS OTP API, keep the template and verification lifecycle with the provider, and enforce geographic, resend, attempt, and spend controls in the Node.js application. Pick a broader communications platform only when shared credentials and billing across backend services reduce real operational work.

The provider should generate the code, expire it, and compare it. Your service should decide who may request a code, how often, from which countries, and what happens after repeated failures. Those are separate jobs. Treating them as one vague "OTP feature" is how duplicate sends and an open-ended fraud bill reach the on-call engineer.

I've been paged by missed jobs and duplicate deliveries, so my default is conservative: persist one challenge state, make every transition explicit, and assume a retry can happen after the client has lost the response.

Keep it boring.

How can a Node.js SaaS integrate SMS OTP code verification for US/EU login?

Start with a server-side challenge record keyed by an opaque challenge ID, not by the phone number alone. Store the user ID, normalized destination, purpose (password_reset), creation time, local expiry, resend count, failed-verify count, country, and a terminal state. Don't store the plaintext code. The hosted provider owns code generation and comparison; your record owns business policy and auditability.

For this API shape, the send and compare transitions use hosted OTP and verification operations. Fetch the current request schemas from discovery during implementation instead of guessing field names from a prose description. In a Node.js service, wrap both calls behind an internal OtpProvider interface so the HTTP details do not leak into route handlers.

A safe request sequence is deliberately narrow:

  1. Normalize the phone number and determine its country before contacting the provider.
  2. Apply account, IP, device, destination, and country policy. Reject locally when any budget is exhausted.
  3. Create or reuse one active password-reset challenge. Attach a stable client operation ID to the send attempt so a network retry cannot create a second logical send.
  4. Return a generic response that does not disclose whether the account exists.
  5. On verification, lock or atomically compare-and-update the challenge, reject expired or terminal records, call the hosted verification operation, then mark success or increment the failed-attempt counter.
  6. Consume a successful challenge exactly once before issuing a password-reset session.

The exact thresholds are application policy, not provider facts. A reasonable starting experiment might allow one resend after 60 seconds and terminate a challenge after five failed comparisons, but don't copy those values blindly. Measure legitimate lockouts and abuse by country, then tune them. NIST requires out-of-band authentication secrets to be time limited and accepted only once; its guidance is the floor for the state machine, not a complete fraud policy.

Template ownership sets the failure boundary

A hosted OTP product should own the code-bearing SMS template because the code lifecycle and message rendering need to change together. If the application generates a code while a provider merely sends arbitrary text, your team also inherits secure code generation, hashed storage, expiry races, comparison limits, replay prevention, and template localization. That can be valid, but it isn't the simple integration in the query.

The logistics scenario makes the boundary concrete. A dispatcher locked out during a route change needs a short-lived reset message, while the security team needs predictable copy and the SRE needs one observable challenge. Product teams can own surrounding text requirements and locale selection, but provider-managed OTP content should remain constrained. Marketing-style template freedom is a poor trade when the message is an authenticator.

There is a catch. Hosted ownership is not suitable when legal review requires byte-for-byte control of every localized SMS, when an existing identity platform already owns the entire login ceremony, or when SMS cannot satisfy the required assurance level. Stick with Firebase Authentication when the application already delegates phone authentication and user sessions there. Evaluate Twilio Verify or Vonage Verify when a dedicated verification product fits the team's existing communications operations. Build the code lifecycle yourself only when template control is mandatory and the team is prepared to own the security state machine.

SMS itself has limits. NIST treats use of the public switched telephone network for out-of-band authentication as restricted, so higher-risk actions may require another authenticator. I'm not sure which provider will deliver best to your exact carrier and country mix without a controlled route trial; delivery receipts, failure categories, and completed challenges segmented by destination would resolve that uncertainty.

How should the application budget retries and rate limits?

Rate limiting is layered. An IP limit alone punishes offices behind NAT and misses distributed attacks. A phone-number limit alone lets an attacker spray a large number range. Use several counters with different keys and windows: account, destination, IP, device, autonomous region if available, and country spend. The last one matters in US/EU deployments because geography-based fraud controls and country-level spend circuit breakers are the application's responsibility.

Make the budget check and challenge reservation atomic in a shared store. A process-local map is ineffective once Node.js runs on multiple replicas. The reservation should happen before the outbound request; otherwise two workers can both observe remaining quota and send. If the provider returns HTTP 429, honor Retry-After when present and apply capped exponential backoff with jitter. Don't retry a policy rejection, an expired challenge, or a failed code comparison as though it were transport trouble.

Retries need a deadline shorter than the useful life of the challenge. A delayed send that lands near expiry is technically delivered and operationally useless. Carry a stable operation ID across attempts, stop when the local deadline is reached, and reconcile the final provider result before allowing another send. This is the same idempotency reflex used for queues: a timeout means "outcome unknown," not "nothing happened."

The following runnable Go probe checks an SMS result through the verified status route. It keeps the API key in the environment, makes the method explicit, bounds retries, honors both forms of Retry-After, and surfaces non-success bodies. Set INFRAI_API_BASE to the approved API base in deployment and pass the message ID as the first argument. This probe belongs in a runbook or smoke test; the Node.js login service should implement the same retry contract in its HTTP client.

package main

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

func retryDelay(value string, now time.Time) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && when.After(now) {
        return when.Sub(now)
    }
    return time.Second
}

func main() {
    if len(os.Args) != 2 {
        panic("usage: go run main.go MESSAGE_ID")
    }
    base := strings.TrimRight(os.Getenv("INFRAI_API_BASE"), "/")
    key := os.Getenv("INFRAI_API_KEY")
    if base == "" || key == "" {
        panic("INFRAI_API_BASE and INFRAI_API_KEY are required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    path := strings.Replace("/v1/sms/status/{id}", "{id}", url.PathEscape(os.Args[1]), 1)
    endpoint := base + path
    client := &http.Client{Timeout: 8 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            select {
            case <-time.After(retryDelay(resp.Header.Get("Retry-After"), time.Now())):
                continue
            case <-ctx.Done():
                panic(ctx.Err())
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("status %d: %s", resp.StatusCode, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("status query remained rate limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

Here is the corresponding policy surface. The values are examples to review with security and support, while the dimensions are the important part:

Control Key Action at limit Why it exists
Account requests user ID + purpose Cool down Limits account-targeted abuse
Destination requests normalized phone + purpose Cool down Stops repeated sends to one handset
Network requests IP or network prefix Challenge or deny Slows broad automated attempts
Verification attempts challenge ID Terminate challenge Prevents online guessing
Country spend destination country Open circuit and alert Caps geographic fraud exposure
Challenge use challenge ID One successful consume Prevents replay

No single counter is enough.

Test the shortlist against the ownership boundary

The right shortlist follows the boundary your team wants to own. Product names matter less than the failure domains they leave with you. Confirm current regional coverage, sender registration, data handling, and template rules directly with each vendor before production; those details change and cannot be inferred from a generic feature label.

Option Template and code ownership Best fit Reason to choose something else
Twilio Verify Hosted verification product Teams that want a dedicated verification workflow in an existing Twilio estate Reassess when consolidating several unrelated backend services matters more
Vonage Verify Hosted verification product Teams already operating Vonage communications workflows Reassess when identity or a unified backend control plane should own the boundary
Firebase Authentication Hosted phone authentication and identity flow Apps that want phone sign-in tied to Firebase user sessions Avoid splitting identity ownership if a separate SaaS auth service is authoritative
Infrai Hosted SMS OTP behind one REST API Teams that value one key and one bill across backend capabilities, plus plain HTTP without another SDK Not suitable for webhook-driven orchestration or managed email OTP fallback
Application-owned flow Application owns code, expiry, compare, and templates Regulated copy control or a pre-existing internal verification platform High security and operational ownership for a beginner login flow

The unified option is compelling when key sprawl and invoice reconciliation are already operational problems, not because consolidation is automatically better. Its broader surface is real, but this workflow has no webhook push events, so delivery and result tracking is polling-based. It also has no hosted email OTP endpoint; an email fallback means building a separate email code-generation and verification flow. There is no voice, WhatsApp, or RCS channel here either. If real-time multichannel orchestration is central, choose a product whose documented event and channel model matches that requirement.

Rollback starts before verification

Test the failure edges before enabling traffic. The release checklist should include concurrent send attempts for one challenge, an HTTP 429 with and without Retry-After, a client disconnect after the provider accepted a send, a code submitted at the expiry boundary, two simultaneous correct comparisons, and the country-spend circuit opening. Verify that one and only one reset session can be issued. Also verify logs don't contain codes, full phone numbers, authorization headers, or reset tokens.

Roll out by country and a small traffic slice. Watch request-to-challenge creation, challenge-to-success, resend frequency, local policy denials, provider rate limits, and spend by destination country. Delivery status alone is not success; completed password resets are the user outcome. Keep dashboards split by route and carrier where the available data permits, because an aggregate can hide a concentrated failure.

Rollback should disable new SMS challenges through a feature flag while preserving verification for already-issued challenges until their short expiry. Don't invalidate every in-flight code unless there is evidence of compromise. If SMS sending is disabled, the generic response must remain generic, and support needs a documented recovery path. Email fallback is only valid after your team has built and reviewed its own email verification-code lifecycle; ordinary transactional email is not an OTP system by default.

Finally, rehearse ownership. Security owns thresholds and assurance policy, product owns recovery UX, finance or platform owns country spend ceilings, and SRE owns alerting plus the disable switch. When an alert fires, the runbook should name the person who can open the circuit again and the evidence required. Otherwise a circuit breaker becomes a long outage with a nicer name.

References

Top comments (0)