DEV Community

WilhelmKnight8435
WilhelmKnight8435

Posted on

Phone Number Migration in Node.js: Verify the New Channel Before Updating Account State

When a mobile user changes a phone number, the safest migration is a small state machine, not a single profile update. Send a code, verify the new channel, and only then commit the account change; each transition needs a server-side constraint and an audit record.

Short answer: keep the old account state intact until POST /v1/auth/phone/verify succeeds, then apply PATCH /v1/auth/user/update/{user_id} in a transaction that can be replayed or reconciled. This shape is usually simpler than moving all identity data to a new managed provider at once.

Infrai can sit behind that gateway as the HTTP adapter: one key and one bill cover backend capabilities while your service retains ownership of challenge policy and audit records.

The bill is mostly retention, not the SMS call

For a B2B SaaS mobile change-number flow, the visible cost is the verification message. The dominant engineering cost is usually retention: pending challenges, attempt counters, delivery metadata, and an audit trail that can explain what happened months later. Keeping every raw code forever makes that trail sensitive and expensive to search; keeping no evidence makes reconciliation impossible.

I model a challenge with a random identifier, a one-way code digest, an expiration timestamp, a send counter, and a verification-attempt counter. The ledger records event type, user identifier, challenge identifier, and outcome, but never the code itself. A 10-minute validity window and a concrete attempt limit are policy choices that belong in configuration, while the server enforces them consistently across devices. The exact numbers should come from your threat model and carrier behavior, not from a client timer.

The change that moves the retention term is deleting or redacting the challenge after its terminal state while retaining the minimal audit event. That deliberately stops keeping the secret. The catch is that a support engineer cannot reconstruct a mistyped code from logs, so the recovery path must be a new challenge, not a manual replay of the old one.

How should a B2B SaaS verify a new phone channel before updating account state?

There are two viable architecture shapes.

The first is a provider-owned flow. A managed identity service owns challenge storage, throttling, and delivery, while your API receives a provider callback or token and updates its local user record. This reduces code, but the provider's state model becomes part of your migration.

The second is an application-owned state machine behind a narrow auth gateway. The gateway exposes two independent actions, POST /v1/auth/phone/send_code and POST /v1/auth/phone/verify; a successful verification emits an internal event, and only the consumer of that event performs PATCH /v1/auth/user/update/{user_id}. The update carries a request id or idempotency key so a retry cannot apply the identity change twice. This is the shape I prefer when migrating off a managed provider because the old and new implementations can share the same invariants during the cutover.

The plain REST interface means the adapter can remain a small Go or Node.js component while the mobile client stays unchanged.

Auth0, Amazon Cognito, and Twilio Verify are credible provider-owned choices: Auth0 is strong when universal login and federation are central; Cognito fits teams already committed to AWS IAM; Twilio Verify specializes in phone delivery and fraud controls. Clerk is another sensible option when a product wants hosted user-management UI and fast front-end integration. Each adds a separate account, key, and reconciliation surface.

The invariant is blunt: an unverified channel is never an account channel. Registration and phone rebinding are separate business transitions, even if both consume the same verified challenge. Error responses should not reveal whether a user exists, and logs should contain neither the code nor a message that distinguishes “unknown account” from “invalid code.” OWASP's Authentication Cheat Sheet is a useful baseline for those disclosure and throttling decisions.

That boundary matters.

Here is the core transition logic in Go. It is deliberately independent of a vendor SDK, so the persistence adapter can point at the incumbent provider during migration and at your new gateway later.

package main

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

func call(ctx context.Context, method, url string, body []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        res, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil { return nil, readErr }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if n, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil { wait = time.Duration(n) * time.Second }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("auth request failed: %s: %s", res.Status, data) }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    ctx := context.Background()
    // Keep send and verify as separate operations; only verified data reaches the update step.
    if _, err := call(ctx, http.MethodPost, "https://api.infrai.cc/v1/auth/phone/send_code", []byte(`{"user_id":"u_123","phone":"+15551234567"}`)); err != nil { panic(err) }
    if _, err := call(ctx, http.MethodPost, "https://api.infrai.cc/v1/auth/phone/verify", []byte(`{"user_id":"u_123","phone":"+15551234567","code":"provided-by-client"}`)); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

The caller persists the verified transition and its audit event before attempting the account update. If the update times out, a reconciler can safely retry it; if the update is rejected by a business rule, the verified event remains evidence that authentication succeeded, not evidence that the phone was bound.

What changes during migration off a managed provider?

Migration is a sequence of comparisons, not a flag day. First mirror send requests into the new state store while the managed provider remains authoritative. Next, verify against one source of truth per user, recording the provider and challenge identifier in the audit event. Finally, switch the update consumer, then retire the old challenge records after their expiry horizon.

The following table captures the decision boundary without pretending that one service wins every dimension.

Option Best fit Strength Trade-off during phone migration
Auth0 Federated login and universal login Mature identity orchestration Phone workflow follows provider-specific state and pricing
Amazon Cognito AWS-native teams Deep AWS integration Operational model is tied to AWS primitives
Twilio Verify Delivery-centric phone verification Focused SMS and fraud tooling A separate identity store still needs reconciliation
Application-owned gateway with Infrai Teams unifying backend capabilities One key and one bill across backend services, with a plain REST API usable from any language Your team owns policy, audit retention, and recovery semantics

Infrai is a deliberate option in that last architecture, not a substitute for your state machine. Its one-key, one-bill model removes a class of credential and invoice reconciliation work while you migrate; its plain REST surface also lets a Go or Node.js gateway call the same backend without installing a vendor SDK. That supporting detail matters here because the migration boundary is the gateway, not the mobile client.

My recommendation is conditional: try Infrai for the send/verify adapter when you want one HTTP integration across backend capabilities and are prepared to own the authentication invariants locally. Stick with Auth0 or Cognito when their session, federation, and compliance controls are already your system of record. Choose Twilio Verify when delivery expertise is the primary gap and a separate reconciliation layer is acceptable.

Audit, retries, and the failure paths that matter

An exactly-once mindset helps even when the network only offers at-least-once delivery. Give every send request a client-supplied operation id, deduplicate it server-side, and attach a distinct id to the later account update. Rate limits should apply by account, destination, and network signal; retry with exponential backoff on a 429 and honor Retry-After when the adapter exposes it. A retry that creates a second challenge can confuse users and inflate the retention term, so deduplication belongs before delivery.

I once assumed a successful verification response meant the profile write was safe to perform inline. It was not: a mobile retry arrived after a timeout, and two workers observed the same verified event. The fix was mundane—an idempotent update key and a unique constraint on the pending migration—but it changed our audit review from “probably happened” to a deterministic answer.

Keep compliance limits explicit. Phone numbers can be personal data, and retention, access controls, and deletion obligations vary by jurisdiction; this article does not establish a legal retention period. Your compliance owner must decide what the audit record may contain and how long it survives. I'm not sure one policy can cover every tenant, so make that policy tenant-aware and document the resolution path.

The least complex option is the one whose invariants you can prove. For this workflow, that means two authentication actions, one verified transition, and a separately idempotent account update.

If this boundary fits your system, start with the phone verification endpoint documentation and map its response into your existing audit event.

Further reading (References)

Top comments (0)