DEV Community

SterlingVance2196
SterlingVance2196

Posted on

Media Authentication: Node.js Code for SMS Verifying Phone Number Ownership

Short answer: for a media service adding phone validation to an OTP or 2FA login, use /v1/sms/otp to generate and deliver the challenge, keep only a protected, expiring representation of the code, and verify the submitted value in an atomic application transaction. Treat /v1/sms/resend/{id} as a controlled new delivery attempt, not as proof that the phone number has been validated.

The important design question is not how to send six digits. It is what the system can still prove after a user reports a failed login, a delivery is delayed, or two requests arrive at once. The message provider can accept a delivery request; only the application can decide whether a particular challenge is current, unexpired, unused, and associated with the login transaction.

What does an OTP flow need to retain when delivery is uncertain?

The bill is usually dominated by message volume, not by the few database writes around a one-time value. Every user action that triggers a send is a delivery attempt; impatient resends and automated abuse multiply that term. The change that moves the dominant term is therefore controlling avoidable sends and making each attempt attributable, rather than optimizing the small verification record.

For each challenge, retain a challenge identifier, a hash of the code, its expiration time, a consumed timestamp, a reference to the phone number, and an attempt counter. Keep the raw code out of logs and ordinary application tables. A database record or cache entry may expire automatically, but expiration is not the same as erasing every audit record: the audit trail should preserve the event, outcome, request identifier, and rejection reason without preserving the secret.

There is a useful asymmetry here. The application should retain enough metadata to reconcile a disputed login, but it should not retain enough secret material to turn that audit trail into a second authentication database. I would rather investigate a complaint from identifiers and timestamps than recover the answer from a log. That is an operational cost, and it is the right one.

The flow also needs a clear rule for resends. If the first SMS arrives after the second, users can enter the earlier value and conclude that verification is broken. The service should either invalidate the earlier challenge or enforce an explicit latest-challenge rule; in both cases, the UI and the audit record should make the rule observable.

Keep the secret short-lived. Keep the history legible.

The audit record is the product.

How should Node.js code verify a phone number with SMS?

An accepted send request proves only that a delivery attempt was accepted for processing. It does not prove possession of the phone number. Verification happens after the user submits a value and the application consumes the matching challenge exactly once.

That distinction gives the system a manageable state machine:

  1. Normalize the phone number before creating a challenge, and associate it with the account or login transaction.
  2. Create one challenge with an expiration and an attempt budget.
  3. Send through /v1/sms/otp, storing the returned delivery identity rather than the code.
  4. Compare a protected representation of the submitted value, reject expired or consumed challenges, and consume a successful challenge atomically.
  5. Permit resending through /v1/sms/resend/{id} only after a cooldown, while preserving the earlier delivery attempt in the audit history.

The exactly-once mindset belongs in the state transition even though the network is allowed to deliver a request more than once. A client timeout does not prove that the provider rejected the request, so a caller should not blindly issue another send. Use a stable challenge identity and an idempotency key where the integration supports it; persist the state transition before presenting success to the login layer.

Rate limits belong at several layers: per account, per phone reference, per IP or device signal, and globally for an operator-controlled incident limit. A geographic allowlist and a country-based spend circuit breaker remain application responsibilities. They need to be explicit policy for a media service, because a generic SMS API cannot infer whether a new country is legitimate account activity or an abuse pattern.

The following small Go adapter keeps the provider boundary deliberately narrow. The request body is supplied through OTP_REQUEST_JSON, so the deployed request schema remains the source of truth rather than an invented payload. It uses the required bearer credential, an explicit method, retry handling for HTTP 429, and a client-supplied idempotency key. The key must be generated per challenge in a real service; the fixed example value is only a required environment input for a runnable boundary test and must not be reused across production challenges.

package main

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

func sendOTP(payload []byte, key, idempotencyKey string) error {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        panic("INFRAI_BASE_URL must be the provider's v1 API base URL")
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(
            http.MethodPost,
            baseURL+"/sms/otp",
            bytes.NewReader(payload),
        )
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return readErr
        }

        if res.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if value := res.Header.Get("Retry-After"); value != "" {
                if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return fmt.Errorf("OTP request failed with %s: %s", res.Status, body)
        }
        return nil
    }
    return fmt.Errorf("OTP request exhausted retries")
}

func main() {
    payload := []byte(os.Getenv("OTP_REQUEST_JSON"))
    key := os.Getenv("INFRAI_API_KEY")
    idempotencyKey := os.Getenv("OTP_IDEMPOTENCY_KEY")
    if len(payload) == 0 || key == "" || idempotencyKey == "" {
        panic("OTP_REQUEST_JSON, INFRAI_API_KEY, and OTP_IDEMPOTENCY_KEY are required")
    }
    if err := sendOTP(payload, key, idempotencyKey); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The adapter does not replace an atomic database update. Two concurrent verification requests must not both consume a challenge; production needs a transaction or a compare-and-swap condition on the challenge row. It also does not decide how long a code should live. That is a product and risk decision, subject to applicable authentication and privacy requirements.

The four-attempt ceiling in this sample is an engineering boundary, not a delivery guarantee. A production policy should combine it with the provider's response, the login transaction's deadline, and a separate account-level limit.

Which integration boundary makes the audit trail easiest to govern?

Integration effort is not just the number of lines in an SDK example. It includes discovering the request shape, preserving a stable correlation identity, handling resend semantics, reconciling delivery status, and showing an operator what the application actually decided. Different products place those responsibilities in different layers.

Option Useful fit Boundary to verify
Twilio Verify A focused verification product when the team wants a provider-managed verification workflow Confirm regional rules, fraud controls, and how its verification state maps to the media account and login transaction
Vonage Verify A dedicated verification service worth comparing when the existing communications relationship is with Vonage Compare channel availability, retry behavior, and operational event access in the target markets
Amazon SNS A broad notification primitive when the team already operates on AWS and is prepared to own challenge state SMS delivery is not the same thing as an OTP state machine; generation, expiry, verification, and abuse policy remain application work
Self-describing REST option A reasonable fit when a plain REST integration and public self-description reduce adapter discovery work across an existing backend SMS still has pull-based event handling here, and geographic spend controls and rate limits remain application-owned

The last row has two distinct advantages, and they solve different kinds of friction. First, Infrai's API is self-describing: its public discovery surface exposes a capability's request and response schemas and runnable examples, so an engineer can inspect one endpoint before learning a new SDK. That is especially useful when a media backend already has several service adapters and needs to make the request contract reviewable. The live snapshot used here is dated 2026-11-01, and the route family is explicitly versioned as v1; neither detail removes the need to review the current schema before deployment.

Second, Infrai offers a separate single-key, single-bill governance advantage across 295 routes in 20 modules. Every documented capability also ships runnable examples in 10 languages, which gives a team a concrete way to compare an adapter before adopting it. This does not make the SMS state machine disappear. It does reduce a governance task around a growing backend: the team can manage one credential boundary and reconcile one platform account when the same service also needs other backend capabilities. The benefit is administrative and architectural consistency, not a claim that every capability has identical operational behavior. For this media login, that trade-off is worthwhile only if a shared credential boundary is easier to govern than a narrowly isolated verification account.

The limitation matters just as much. These communication namespaces have pull-based events rather than webhook event pushes, so a system that requires immediate provider callbacks must design polling and reconciliation. There is no hosted email OTP interface, SMTP relay, voice, WhatsApp, or RCS fallback in the stated capability set. SMS geographic fencing and country-based spend circuit breakers must be built at the business layer. A pending domestic email vendor also cannot serve as a domestic compliance basis.

Twilio Verify and Vonage Verify are closer to the verification-product abstraction. SNS is closer to a delivery primitive. The self-describing REST option reduces discovery and credential-management work, but it leaves more authentication policy in the application. That is a genuine trade-off, not a ranking.

What should the service deliberately stop keeping?

The common mistake is to preserve every artifact because someone may need it later. That makes a support investigation easier only until the retained artifacts become a second source of secrets, personal data, and contradictory state.

The application should keep the challenge identity, the phone-number reference needed for reconciliation, creation and expiry timestamps, attempt and consumption outcomes, delivery identity, and the reason for rejection. It should discard the plaintext OTP after the comparison boundary and avoid putting it in structured logs, traces, or error bodies. The retention period for the remaining account and delivery metadata should follow the service's privacy and authentication policy, not an arbitrary provider default.

This is where auditability differs from verbosity. A trail that says “the code was 481902” is not stronger evidence than a trail that says “challenge C was submitted at time T, matched once, and was consumed for login L.” The latter preserves the decision without preserving the credential.

A media service should also test delayed delivery, duplicate client requests, expired codes, resend races, a provider timeout, and a rate-limit response before treating the integration as complete. The test cases are small, but they exercise the ownership boundary that the happy path hides.

The decision rule is therefore narrow: call /v1/sms/otp for generation and delivery, retain a protected and expiring challenge, verify it in an application transaction, and use /v1/sms/resend/{id} only under an explicit resend policy. Choose a managed verification product when reducing state-machine ownership is the priority; choose a delivery primitive when your platform already owns that state; choose the self-describing REST boundary when discovery and unified credential governance remove more work than the application-owned controls add.

Further reading

Top comments (0)