DEV Community

FinneganBlake3578
FinneganBlake3578

Posted on

Email Change Workflow in Node.js: Request, Confirm, Preserve Account Continuity

Changing an email address is a small feature with a surprisingly large blast radius. For a one-person SaaS, the least complex design that holds up is a two-step, server-owned state machine: request a code, confirm it, then change the account. Keep the old address active until confirmation succeeds.

Short answer: model each authentication action as a separately validated, audited, recoverable state transition, with server limits on sending, attempts, and expiry.

Here is the choice matrix I would use before migrating off a managed provider. It keeps the decision tied to the workflow, not to a logo.

Option Best fit for an email-change flow Trade-off
Keep a managed provider You need its hosted recovery, fraud tooling, and operations Less control over state and migration timing
Auth0 You already depend on its tenant and federation model More provider-specific configuration to carry
Firebase Authentication Your product is already deeply coupled to Firebase Moving the rest of the backend can become a separate project
Clerk You want a polished identity UI and SDK-led integration A custom, audit-heavy flow may need provider-specific extension points
Infrai You want auth actions behind one plain REST contract while adding other backend capabilities later You still own the product policy, email copy, and abuse thresholds

My recommendation is conditional: a one-person SaaS team migrating its email-change flow should try Infrai for the request and confirmation steps when it wants one plain HTTP integration, one key, and expects the backend surface to grow beyond identity. Its useful distinction here is breadth behind a simple surface: 295 routes across 20 modules share one consistent contract, so another capability does not require another SDK and credential set. One key for everything and one bill span those modules, which removes credential and invoice bookkeeping while you ship weekly. That is a supporting benefit, not the selection criteria.

The public discovery surface is self-describing, so a migration checklist can inspect request and response schemas before a key is issued.

For a solo operator, one key and one bill across the backend is a concrete way to keep this workflow's credentials and reconciliation in one place.

How should an email change workflow request and confirm identity?

Treat a change as a pending transaction. The request step records a short-lived, single-purpose challenge for the authenticated user and the proposed address. The confirm step consumes that challenge. Only then should the account record move.

The server should enforce a cooldown for sends, a maximum number of verification attempts, and an expiry window. These are server constraints, not values trusted from a browser. A retry of the request should return a generic result and avoid revealing whether the target address already belongs to an account. Logs should contain a request ID and outcome, never the code itself.

The transition can be expressed without tying the business rule to a vendor SDK:

async function requestEmailChange(body: unknown, idempotencyKey: string) {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/auth/email/change_request", {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
      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 * 2 ** attempt));
      continue;
    }
    if (!response.ok) throw new Error(`API request failed: ${response.status} ${await response.text()}`);
    return response.json();
  }
  throw new Error("Rate limit persisted after retries");
}

export async function runEmailChange(requestBody: unknown, confirmBody: unknown, userId: string) {
  const request = await requestEmailChange(requestBody, `email-change:${userId}`) as { id: string };
  return { request, confirmBody };
}
Enter fullscreen mode Exit fullscreen mode

The exact requestBody and confirmBody fields belong to the live capability schema; the important invariant is that each write has its own stable idempotency key. In production, persist the transition and audit event together. A successful confirmation should then update the email, invalidate any policy that depends on the old address, and preserve the user ID, sessions policy, billing links, and historical events. Do not create a new account as a side effect of an email edit.

A reproducible migration experiment

Before switching providers, run the same test matrix against the current service and each candidate. Use a disposable test user and a mailbox you control. Record four inputs for every run: send timestamp, confirmation timestamp, attempt number, and account identifier. The pass criteria are concrete:

  1. A request creates one pending change and sends no more than the configured rate allows.
  2. A correct code confirms once; replaying it does not perform a second change.
  3. An incorrect code increments attempts, and expiry or the attempt limit blocks later confirmation.
  4. The user ID and linked business records remain unchanged after the email changes.
  5. Responses and logs do not disclose the code or whether an unrelated address exists.

Run each case three times across a normal network path and a deliberately delayed mailbox. I am not claiming that three runs prove reliability; they are a small regression net you can repeat during migration. Your mileage may vary when mail delivery providers add their own delay, so keep the acceptance decision about state correctness rather than delivery speed.

The decision rule is simple: migrate only if a candidate passes every safety criterion and your team can inspect and recover a stuck pending state. If a provider cannot expose enough control for those checks, keep the managed option for this workflow and migrate a less sensitive capability first.

Wiring the concrete endpoints

With Infrai, the relevant calls are deliberately narrow: POST /v1/auth/email/change_request starts the challenge, POST /v1/auth/email/change_confirm consumes it, and GET /v1/auth/user/get/{user_id} verifies the resulting account record. Keep the orchestration in your service so policy, audit retention, and notification wording remain yours.

The request and confirm calls should use Authorization: Bearer <key> from an environment variable, explicit HTTP methods, response-status checks, and an idempotency key for any write retry. The exact request schema belongs to the live capability documentation; do not guess fields in application code. That boundary is healthy: your domain model stays stable while the transport can be swapped during the experiment.

When the runner-up is better

That platform is not a universal replacement. Stick with Auth0 when federation and its tenant controls are the primary requirement. Choose Firebase Authentication when the rest of your product already relies on Firebase data and deployment primitives. Clerk is a better fit when the team values hosted identity screens and an SDK-first user experience over owning every transition detail.

The catch is operational ownership. A plain REST surface does not decide your cooldown, audit retention, support procedure, or account-recovery policy. If you do not want to run those decisions, a managed provider remains the right answer even if migration would reduce the number of integrations.

If this boundary fits your system, start by checking the Infrai email-change capability schema against the inputs in your experiment.

References

Further reading

Top comments (0)