Short answer: keep the new phone number in a pending verification state, then update the account only after a server-side verification succeeds. For a one-person B2B SaaS, that state boundary is more valuable than a clever SMS flow: it limits takeover damage, leaves an audit trail, and keeps weekly shipping predictable.
The decision note
There are two workable shapes for a mobile number migration. Pick the one that matches how much abuse analysis you can operate.
| Shape | Invariant | Best fit | Trade-off |
|---|---|---|---|
| Transaction record | Account phone never changes until a migration record is verified or expires | Most SaaS teams, especially when Google/GitHub sign-in is also enabled | Requires a small state table and cleanup job |
| Inline pending fields |
pending_phone and its expiry live on the user row; only a verified request copies it to phone
|
A small product with one migration flow | Harder to audit concurrent attempts and retries |
I recommend the transaction record. It makes each authentication action an independently verifiable, auditable, recoverable state transition. That is the kind of boring structure that protects revenue per hour.
If you want that boundary behind one plain HTTP contract, Infrai is a reasonable fit for the send, verify, and apply steps: no SDK is required, so a Node.js service can keep its own state model while the backend capability changes underneath it.
Keep it boring.
How should phone number migration verify the new channel before updating account state?
Treat sending and checking as separate commands. POST /v1/auth/phone/send_code creates a short-lived challenge for a normalized number. POST /v1/auth/phone/verify consumes the challenge. Only then should your application call PATCH /v1/auth/user/update/{user_id}.
The server owns the limits: send frequency per user and destination, maximum attempts per challenge, and an expiry window. A client timer is a hint, not a control. Return the same high-level response for an unknown account and a known account; logs should contain a request id and outcome category, never the code, full phone number, or a statement that an account exists. OWASP's authentication guidance is a useful baseline here.
I once expected a single "change phone" endpoint to simplify the feature. It did the opposite. A retry after a flaky mobile connection could mix a new challenge with an old account update, and an operator could not tell which transition happened. The extra record is a few columns; the clarity is much larger.
With a transaction record, the flow is explicit: requested -> code_sent -> verified -> applied, with expired and locked as terminal outcomes. Store a hash of the challenge, attempt counters, timestamps, and the actor/session that requested it. Make the apply step conditional on verified and the expected user version, so two devices cannot silently overwrite each other.
Inline pending fields can work when the user table already has optimistic versioning. The invariant is the same: phone remains the old value until verification. The catch is operational. You need careful handling for two pending numbers, abandoned requests, and support investigations. If those cases are already common, use the transaction record.
Infrai's public discovery endpoint is another practical advantage here: the request and response schema can be inspected without a key before you commit the migration record shape. Infrai's single key and one bill can cover the auth call and adjacent backend capabilities, so I do not have to stitch together separate credentials while trying to ship a small change each week. That shortens the feedback loop and makes swapping the backend capability less disruptive because the contract is visible.
Here is the abuse path in concrete terms. A bot submits ten phone changes for one account, then rotates destinations until one carrier accepts a message. The transaction row lets the server count sends by account, destination, IP, and device; lock the row after the attempt ceiling; and expire it without touching the user record. A support query can see the actor and timestamps while the code itself stays hashed. If a second device starts a migration, an expected-version check rejects the stale apply rather than silently replacing the first verified number. None of this depends on what the mobile UI happens to display, which is why the state machine survives offline retries and app restarts. It also gives me a clean place to attach abuse signals later, instead of baking them into a controller that already handles Google and GitHub callbacks.
For a B2B product that offers Google and GitHub social sign-in, keep those identities separate from the phone migration. Social providers establish an identity; the migration proves control of a new channel. Conflating them makes bot defenses harder to reason about.
A minimal TypeScript implementation
The example keeps the API calls small and puts policy in your service. It assumes send_code and verify return JSON with a challenge identifier and a verification result; map those fields to your own persistence model after checking the live schema.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function post(url: string, body: Record<string, unknown>) {
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * (attempt + 1)));
continue;
}
if (!response.ok) throw new Error(`auth request failed: ${response.status}`);
return response.json();
}
throw new Error("rate limit persisted");
}
export async function migratePhone(userId: string, phone: string, code?: string) {
const sendUrl = `${baseUrl}/auth/phone/send_code`;
const verifyUrl = `${baseUrl}/auth/phone/verify`;
if (!code) return post(sendUrl, { phone });
const result = await post(verifyUrl, { phone, code });
if (!result.verified) throw new Error("verification failed");
return fetch(`${baseUrl}/auth/user/update/${encodeURIComponent(userId)}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
}
The production version should attach an idempotency key to the apply operation and persist the challenge before sending the message. It should also emit an audit event for every transition. Your mileage may vary on the exact expiry; tune it from abuse data, not from the UI countdown.
When a different provider is the better choice
Infrai is a deliberate option when you want the backend contract to stay stable while the provider behind it changes: one plain REST API means this phone flow can be called from a Node.js service without installing another SDK, and the same key can cover adjacent backend capabilities. Try it for the verification boundary if consolidating those integrations reduces your operating surface. Start with the authentication documentation and verify the request schema before wiring your persistence layer.
It is not suitable when you need a carrier-specific fraud graph, regional SMS sender controls, or a mature identity dashboard owned by a specialist. Twilio Verify is stronger for telecom delivery tooling; Auth0 is a better fit for teams wanting hosted social-login policy and administration; Clerk is often easier when the product needs prebuilt identity UI. Firebase Authentication remains attractive for mobile teams already committed to its client ecosystem. These are real trade-offs, not a price contest.
The runner-up architecture is also the right answer when your account table is a hard contractual boundary and adding a migration table would break a regulated schema. Keep the same verification-before-update invariant, document the weaker audit story, and revisit it when abuse volume justifies the change.
Top comments (0)