Email change APIs get safer when proof is tied to the session that asked for it, not just to a token floating around later.
Why email change APIs fail in quiet ways
The risky part of an email change flow is rarely the happy path. It is the gap between "user asked to change address" and "backend accepted the new address as trusted". I keep seeing systems that issue a verification token, save a pending email, and call it done. That works until a stolen session, a replayed link, or a retried request lands at the wrong time.
Three failures show up a lot:
- The verification link is opened from a different auth state than the one that requested it.
- A second change request silently overwrites the first pending email.
- Support can see that a change happened, but cannot explain which session actually approved it.
Those bugs are annoying because the API still looks clean from the outside. Internally, though, the trust boundary is mushy. For Authentication work, I want one narrow rule: the proof for changing an address must stay attached to the session context that initiated it, or at least to a re-auth event derived from that context.
Bind proof to the active session
My preferred model is simple. When POST /account/email-change is called, the API creates a pending change record with:
- current user id
- current session id or re-auth event id
- old email
- requested new email
- hashed verification token
- expiry time
- state
That extra session binding looks boring, but it removes a lot of guesswork later. If the link comes back with a valid token but the originating session was revoked, stepped up, or replaced by a more recent request, the API can reject the change cleanly. That is the kind of boring failure I like.
In practice, I use a short re-auth window before starting the change. Password prompt, passkey assertion, or MFA step are all fine. The important bit is to persist the proof artifact you actually trust, not just the resulting token. Otherwise the token becomes a tiny passport with very little context, and thats where weird account-takeover stories begin.
A Node.js flow that survives retries
In Node.js, I treat the request flow as a state machine instead of a pair of loose handlers. Roughly:
- Require recent re-auth.
- Insert a
pending_email_changerow. - Invalidate older pending rows for the same user.
- Send the verification email with an idempotency key.
- On link open, verify token hash, session proof, expiry, and row state in one transaction.
- Swap the account email and mark the row
consumed.
A stripped down service layer might look like this:
async function startEmailChange(db, userId, sessionProofId, nextEmail) {
await db.tx(async (tx) => {
await tx.query(
`update pending_email_change
set state = 'superseded'
where user_id = $1 and state = 'pending'`,
[userId]
);
await tx.query(
`insert into pending_email_change
(user_id, session_proof_id, next_email, token_hash, state, expires_at)
values ($1, $2, $3, $4, 'pending', now() + interval '20 minutes')`,
[userId, sessionProofId, nextEmail, issueTokenHash()]
);
});
}
The point is not the exact SQL. The point is that retries should converge on one active intent. If a mobile client resends because the network is flaky, or the browser double-submits, the flow should stay a bit boring. That same idea also helps when you want one email source of truth across frontend and backend validation.
On the callback side, I avoid a "token matches, therefore approve" shortcut. I check whether the stored session proof is still valid for the account and whether the pending row is still the latest one. This is one place where engineers sometimes wave away risk because the token was emailed, but email ownership alone is not always enough for sensitive profile changes.
What to persist for operators
If the flow causes an incident, operators need more than a boolean success flag. I normally persist:
requested_atverified_atsession_proof_idsuperseded_byfailure_reason-
request_ip_hashor trusted device marker
That gives support and security teams a usable timeline without turning the system into a surveillance mess. It also makes it easier to answer questions like:
- Did the same user request two different addresses?
- Was the earlier request still pending when the second one started?
- Did verification happen after the re-auth window expired?
I also log a stable reason code for each rejection. "expired", "superseded", "stale_session_proof", and "already_consumed" are much better than a fuzzy "invalid token" bucket. Small detail, big payoff later.
For some teams, statistics help win the argument. Microsoft has repeatedly documented that MFA and stronger sign-in verification drastically reduce account compromise risk, which is why binding sensitive account changes to recent proof is not just security theater (source). Numbers like that should guide the control, but the daily work is still about clean backend state.
Testing without fooling yourself
I do not like tests that only assert "some email arrived". For this flow, the useful cases are:
- Two rapid change requests leave only one pending row.
- A verified token from the first request fails after the second request supersedes it.
- A token with the right hash still fails if the bound session proof is stale.
- The audit trail explains every rejection path.
Manual tests with a disposable inbox can still be handy, especially when QA wants to inspect the full email body. I just keep that outside the trust model. If somebody writes tem email in a test plan, fine, but the backend should never depend on inbox tooling to decide whether an account change is legit.
This is also where email retry boundaries in tests matter. If the test runner keeps polling forever, you can miss the fact that your API produced two sends or approved the wrong request. Tight retry windows catch more real bugs, even if they feel a little less comfy.
Q&A
Should the verification link only work in the same browser?
Not always. For many products that would be too strict and kind of annoying. What should stay bound is the proof record, not necessarily the exact browser tab. If you allow cross-device completion, require the callback to reference a recent re-auth artifact that is still valid for that account.
Do I need this for low-risk products?
If users can change the email that controls password resets, invoices, or admin notices, yes probably. It is a compact control with pretty good leverage, and it ages well as the app grows.
What if the user requested the change but lost the session?
Let them restart the flow. That is cleaner than trying to rescue a half-trusted request. Security UX is better when the recovery path is obvious, even when it adds one more click or two.
Session-bound proof turns email change APIs from "token says yes" into "system can explain why yes". For backend teams, that difference is not flashy, but it saves a lot of messy debugging later and makes the whole flow feel more honest.
Top comments (0)