DEV Community

OberonJohansson6982
OberonJohansson6982

Posted on

Verifying New Channels During Phone Number Migration with Node.js Account Recovery

Short answer: treat a phone-number change as a recovery transaction, not a profile update. Verify the new channel, keep the old recovery path alive until the transaction settles, and only then change account state. In a customer-support app that also accepts Google and GitHub sign-in, this prevents a support agent from losing the only route back into an account after a recycled number is claimed.

The constraint that changed my implementation was recovery, not login. A successful OAuth callback proves that Google or GitHub authenticated a subject. It does not prove that the person requesting a phone migration controls the new number, or that the old number should be discarded right now. Those are separate proofs.

What should a Node.js phone number migration verify before changing account state?

I model the change as a short-lived transaction with an explicit state machine: requested, new_channel_verified, committed, and cancelled. The account's phone_number and recovery metadata remain unchanged until committed. A code sent to the new number is one factor. A recent session, an existing recovery factor, or a fresh Google/GitHub reauthentication is a second, independent factor.

The first version of this flow updated the phone column as soon as the SMS provider returned a delivery acknowledgment. That was a category error. Delivery means an attempt was made; it is not possession. The fix was a pending record with a random nonce, an expiry, and a hash of the destination. Store the hash, not the raw code.

A support workspace needs an additional guard. Agents can help a customer start a recovery case, but an agent action must not silently satisfy the customer's possession check. Record who initiated each step, which session or identity assertion was used, and the reason entered for a manual review.

The useful rule is boring: no verified new channel, no account-state mutation.

The smallest working Node.js boundary

This TypeScript example keeps transport, code delivery, and account persistence behind tiny interfaces. It has no framework assumptions, so it is easy to exercise with a fake clock and in-memory stores before wiring a database.

type Migration = {
  id: string;
  accountId: string;
  oldPhoneHash: string;
  newPhoneHash: string;
  codeHash: string;
  expiresAt: number;
  state: "requested" | "new_channel_verified" | "committed" | "cancelled";
  attempts: number;
};

interface MigrationStore {
  get(id: string): Promise<Migration | null>;
  save(migration: Migration): Promise<void>;
}

interface AccountStore {
  setPhone(accountId: string, phone: string): Promise<void>;
}

interface CodeHasher {
  matches(input: string, digest: string): Promise<boolean>;
}

export async function verifyAndCommitPhone(
  migrationId: string,
  code: string,
  now: number,
  store: MigrationStore,
  accounts: AccountStore,
  hasher: CodeHasher,
  newPhone: string,
): Promise<void> {
  const migration = await store.get(migrationId);
  if (!migration || migration.state !== "requested" || migration.expiresAt <= now) {
    throw new Error("invalid migration");
  }
  if (migration.attempts >= 5) throw new Error("too many attempts");

  migration.attempts += 1;
  const valid = await hasher.matches(code, migration.codeHash);
  if (!valid) {
    await store.save(migration);
    throw new Error("invalid migration");
  }

  migration.state = "new_channel_verified";
  await store.save(migration);
  await accounts.setPhone(migration.accountId, newPhone);

  migration.state = "committed";
  await store.save(migration);
}
Enter fullscreen mode Exit fullscreen mode

The persistence boundary needs one more detail in production: commit the account update and transaction state in the same database transaction, or use an outbox that retries the state transition idempotently. If the process stops after setPhone but before the final save, a reconciliation job should compare the account version and migration record rather than sending another code.

I return the same public error for an unknown migration, an expired code, and a wrong code. Internal events can still distinguish them. OWASP's Authentication Cheat Sheet calls out generic authentication responses because different errors help attackers enumerate accounts.

How do Google and GitHub sign-in affect recovery paths?

Social sign-in is an account identifier, not a universal recovery key. Link a provider subject to an internal immutable account ID, then require a recent provider assertion when risk is high. Never use an email string as the sole join key: addresses can change, and an email claim does not establish control of a phone number.

For a customer-support product, the recovery screen should show the customer which channels remain available: old phone, new phone pending verification, and linked providers. It should not reveal whether an arbitrary email or phone belongs to an account before the requester has authenticated. A customer who still has a valid Google session can reauthenticate and start the migration; a GitHub-only account can use the same route, but the policy should be explicit and logged.

I test the dangerous interleavings, not just the happy path. Two verification requests for the same account must serialize. A second request must invalidate the first nonce. A retry after a network timeout must be safe. A code entered after five failures must not extend the expiry.

Five attempts. Then stop.

What changes at support-team scale?

At scale, the hard part is the queue around the transaction. Add rate limits per account, destination, IP, and support case. Emit structured events such as phone_migration.requested, phone_migration.verified, and phone_migration.committed, with actor IDs and correlation IDs but no phone numbers or codes. Alert on bursts of requests targeting many accounts or repeated agent overrides.

Use a monotonic account version so concurrent profile writes cannot overwrite a newer phone. Keep the old number as a recovery alias for a short, documented grace period when policy permits; mark it as retiring rather than deleting it immediately. The grace period is a product decision, and it must be visible to support staff.

Your mileage may vary on the exact window. A regulated service may require a longer hold and manual review, while a low-risk forum may choose a shorter one. I am not sure a single timeout works across both; measure completion rate, lockout rate, and confirmed takeover reports, then set the policy from those observations.

Trade-offs and the boundary of the design

The catch is friction. Requiring two proofs blocks legitimate customers who lost their old phone and their social session at the same time. A staffed support process can offer identity review, but that path needs separate controls, delayed execution, and an audit trail; it should not flip phone_number directly from a chat transcript.

This design is not suitable for an offline-first client that cannot contact a server to verify possession. Use device-bound credentials and a constrained recovery capability there. It is also a poor fit when the team cannot operate rate limiting, durable event logs, and a reconciliation worker. Stick with a simpler, provider-managed recovery flow when those operational controls are outside your ownership, accepting less control over the state machine.

The decision is therefore about failure containment. Verify the new channel, preserve a recovery path, serialize the commit, and make every exceptional route observable. That sequence works across SMS vendors and identity providers because it relies on possession proofs and transaction semantics, not a particular SDK.

Further reading

References:

Top comments (0)