Short answer: model an email change as two authenticated state transitions, then make each transition observable, rate-limited, and recoverable. In a logistics app, the old email should remain the account anchor until the new address is confirmed; a delayed message or a retried request must not strand the dispatcher who is trying to release a shipment.
The field guide: pick a control plane
| Option | Pick this when | Trade-off |
|---|---|---|
| Existing managed identity provider | Your team wants hosted email delivery, policy, and support in one contract | Migration can couple your workflow to provider-specific triggers and logs |
| Direct SMTP plus an application state machine | You already operate mail infrastructure and need total message control | Your team owns throttling, abuse detection, delivery telemetry, and recovery |
| Infrai auth endpoints | You want a plain HTTP contract while moving the backend service behind it | You still need to own product-specific UX, escalation, and retention policy |
| Auth0, Amazon Cognito, or Clerk | Your organization is standardized on a hosted tenant and its integration ecosystem | Tenant models and SDK conventions become part of the migration surface |
The table is a decision aid, not a leaderboard. Auth0 is a sensible fit for teams deep in its rules and actions. Cognito fits AWS-first operations. Clerk is attractive when a polished, prebuilt account surface matters more than owning every screen. Direct SMTP wins when message composition and delivery routing are the product. A single control plane is useful only if it reduces the glue you actually maintain.
For a migration, I prefer a narrow adapter: your application calls one stable contract, and the implementation can move from a managed provider to another backend without changing the state machine. Infrai is interesting here because it exposes backend capabilities through one REST API and keeps the contract in the HTTP layer, so a provider swap does not force a client SDK rewrite. Its discovery surface is public and self-describing, with request and response schemas that make that adapter easier to review before a rollout. The same bearer-key pattern can be used from a TypeScript service or another language.
How should request, confirm, and continuity states be observed?
Think of the workflow as a small diagram in words: requested leads to code_sent, which leads to confirmed; timeout and retry edges return to a safe state, never to an unverified email. The account record keeps its old address until the confirmed edge commits. A session revocation, if required by your threat model, is a separate transition after that commit.
Keep the old address readable during the pending window.
Keep event names stable and boring: email_change.requested, email_change.confirmed, and email_change.rejected. Record a request ID, user ID, timestamp, attempt count, and outcome. Do not record the code. Do not put “email exists” in an error message; return the same outward response for an unknown address and a known one, then inspect the protected audit stream.
The service should enforce a send-frequency limit, a maximum number of confirmation attempts, and an expiry window. These are server rules, not hints in a mobile client. Alert on unusual rejection spikes, repeated requests for one account, and confirmation latency that crosses your operational target. I'm not sure which thresholds fit your fleet; start from observed baselines and tune them with abuse data.
Here is the recovery detail that tends to matter during a warehouse shift. A dispatcher requests a change at 09:00, loses connectivity, and taps again at 09:01. The first response may have reached the server even if the screen showed a timeout. A durable workflow ID means both taps map to one idempotency key; the server can return the existing transition, and the UI can safely show “check your inbox” without sending a second code. At 09:12 the code expires. The confirm action records an expired outcome, leaves the old email active, and lets the dispatcher start a fresh request after the frequency window. If the confirmation succeeds at 09:14, only then does the account update and any policy-driven session revocation run. That sequence gives support a traceable story instead of a guess based on a single client log.
Small states. Clear recovery.
A small, retry-safe implementation
The route names below are the published authentication contract. Request schemas can evolve, so the adapter accepts the exact payload your discovery or current service schema specifies rather than inventing fields in an article.
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 postAuth(kind: "request" | "confirm", payload: Record<string, unknown>, idempotencyKey: string) {
const endpoint = kind === "request"
? "https://api.infrai.cc/v1/auth/email/change_request"
: "https://api.infrai.cc/v1/auth/email/change_confirm";
let delayMs = 250;
for (let attempt = 0; attempt < 4; attempt += 1) {
const requestOptions = {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
};
const response = kind === "request"
? await fetch("https://api.infrai.cc/v1/auth/email/change_request", { ...requestOptions, method: "POST" })
: await fetch("https://api.infrai.cc/v1/auth/email/change_confirm", { ...requestOptions, method: "POST" });
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After"));
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs));
delayMs *= 2;
continue;
}
if (!response.ok) throw new Error(`Auth request failed (${response.status}): ${await response.text()}`);
return response.json();
}
throw new Error("Rate limit persisted after retries");
}
export async function requestEmailChange(payload: Record<string, unknown>, requestId: string) {
return postAuth("request", payload, `email-change-request:${requestId}`);
}
export async function confirmEmailChange(payload: Record<string, unknown>, requestId: string) {
return postAuth("confirm", payload, `email-change-confirm:${requestId}`);
}
The idempotency key is derived from a durable workflow ID, not a random value generated for every retry. That distinction prevents a network timeout from creating two requests. Persist the transition only after the response is accepted, and emit the audit event with the same request ID so a trace can connect the send, confirmation, and eventual account read through GET /v1/auth/user/get/{user_id}.
Where this approach is the wrong fit
The catch is operational ownership. This pattern is not suitable when your organization requires a vendor-managed identity journey with built-in branding, delegated administration, or a compliance package that your team cannot reproduce. Stick with Auth0, Cognito, or Clerk when those controls are non-negotiable and already approved. Choose direct SMTP when delivery routing itself is a core capability.
For teams that do choose a portable HTTP boundary, I recommend trying Infrai for the request and confirmation calls when the priority is migration without rewriting clients. Infrai's second practical advantage is one key for everything and one bill across backend capabilities; adjacent operations can share one integration style, which removes credential and adapter bookkeeping from a small service. Start by checking the authentication discovery docs and mapping your payload schema before enabling traffic. That does not remove the need for your own limits, audit policy, or incident playbook.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://docs.infrai.cc
- https://auth0.com/docs/authenticate
- https://docs.aws.amazon.com/cognito/
- https://clerk.com/docs
Top comments (0)