Changing an account email looks simple in product mocks, but the backend path is easy to get wrong. The request touches identity, notification, and recovery flows at the same time, so a small retry bug can leave a user with two valid links or a silently stale address. I have seen this happen in otherwise careful services, especialy when the API treated "token created" as the end of the job.
Why email change flows replay more than teams expect
Replay bugs usually show up at the boundary between the authenticated request and the later confirmation click. A POST /account/email-change call writes a pending address, creates a token, and sends a message. Then the user opens the mail client twice, the gateway retries once, or support triggers a resend while the first token is still alive.
Three patterns cause most of the mess:
- The service stores only the newest token, so it cannot explain which link was clicked.
- Resend logic invalidates too late, leaving a short period where two links both work.
- Audit logs show "email updated" but not which request or token actually did it.
For auth systems, that is not just annoying. Email is often the recovery channel and the notification channel, so a replay bug can realy confuse incident response. When a user says "I clicked the older email by mistake", the system should have a crisp answer instead of a shrug.
The token contract I use in production
The contract I like is boring on purpose:
-
users.emailstays unchanged until the confirmation click succeeds. -
email_change_requestsstores the old address, the proposed address, a token hash, expiry, and asuperseded_attimestamp. - Every resend creates a new row and supersedes earlier still-open rows for the same user.
- The confirmation endpoint accepts exactly one active row inside a small replay-safe transaction.
That last point matters more than teams first think. If two browser tabs submit the same token within a second, I want one winner and one clean "already used" response. If a user opens an older link after a resend, I want "superseded" and not a vague invalid-token page.
I also keep the reason code close to the row. States like pending, confirmed, expired, and superseded are enough for most products. You do not need a large workflow engine here, but you do need a lifecycle that support and security can inspect in minuts.
One small practice has saved me more than once: never reuse the same row for a resend. A fresh row keeps the audit trail legible and avoids state that is slighly impossible to reason about later.
A Node.js and PostgreSQL implementation
My write path usually starts by locking the user row, then creating a pending request:
await db.tx(async (trx) => {
const user = await trx.one(
`select id, email
from users
where id = $1
for update`,
[userId]
);
await trx.none(
`update email_change_requests
set superseded_at = now()
where user_id = $1
and confirmed_at is null
and superseded_at is null
and expires_at > now()`,
[user.id]
);
await trx.none(
`insert into email_change_requests
(user_id, current_email, next_email, token_hash, expires_at, request_id)
values ($1, $2, $3, $4, now() + interval '20 minutes', $5)`,
[user.id, user.email, nextEmail, tokenHash, requestId]
);
});
The confirmation path is where the replay guard lives. I normally update the request row and the user row in one transaction, with a predicate that only matches an active token once:
with claimed as (
update email_change_requests
set confirmed_at = now()
where token_hash = $1
and confirmed_at is null
and superseded_at is null
and expires_at > now()
returning user_id, next_email
)
update users
set email = claimed.next_email
from claimed
where users.id = claimed.user_id
returning users.id, users.email;
If that second statement returns zero rows, I look up the request state and return a precise response. 410 Gone works well for expired or superseded tokens; 409 Conflict is reasonable for a token already consumed. The important thing is consistency, because clients and support tooling both start to depend on it.
The reason I prefer this design in a REST API is that it scales without extra magic. PostgreSQL handles the concurrency. Node.js just carries request ids, auth context, and metrics. There are fewer hidden dependancies, which makes on-call debugging calmer.
According to the 2024 Verizon Data Breach Investigations Report, stolen credentials and auth abuse remain a major factor in real incidents, which is why I do not like fuzzy confirmation flows around account identity changes: https://www.verizon.com/business/resources/reports/dbir/. Email change is not the only control, but it is part of the trust boundary.
What I verify in staging before shipping
I do not stop at "mail arrived". For this feature, I want four checks:
- A first token confirms the change and updates the account exactly once.
- A resend marks the older token as superseded before the new message is sent.
- A double-click on the same confirmation link produces one success and one deterministic failure.
- Audit records join cleanly by user id, request id, and token state.
This is where isolated inboxes help, even if the article itself is not about email tooling. Teams often leave fixture labels like tempail mail or tamp mail com in staging helpers, and that is usually a sign the test path grew organically rather than intentionally. I try to keep one inbox per test run and one request id per email-change attempt.
If you already have reset delivery drift checks in place, reuse the same assertion style here. The browser test should not just open the latest message it sees. It should target the exact request id or scenario id. That matches the same thinking behind stable inbox scenario ids, and it makes flaky auth tests a lot easier to explain.
One more thing: emit a support-facing event when a token is superseded. Not because the product UI always needs it, but because the audit trail often does. When somebody says "your system changed my address without warning", that breadcrumb matters a ton.
Q&A
Should I invalidate older email-change tokens immediately on resend?
Yes, if the user intent is "use the latest email only". Keeping parallel valid tokens almost never helps, and it makes abuse analysis harder.
Do I need a separate table for email-change requests?
I think so. Reusing a generic token table can work, but teams often regret it once they need better audit detail, clearer states, or support tooling.
What if the user changes the target address twice in one session?
Create a new request each time and supersede the old one. That keeps the flow honest and avoids teh weird case where one confirmation email mutates into another meaning.
Top comments (0)