Short answer: keep an immutable account ID, treat an email address as a verified login attribute, and make the address change a short-lived, auditable transaction; this preserves history while allowing a marketplace to raise or lower login friction from device risk.
Boundary first.
In a payment or ledger backend, I start with the identity boundary, not the profile screen. An address is mutable evidence. The account ID is the subject that owns balances, disputes, tax records, and an audit trail. Creating a second account because a buyer changed old@example.com to new@example.com creates reconciliation work that no fraud rule can reliably repair later.
The invariant: one subject, many addresses over time
Store a generated, opaque account identifier in every foreign key. Keep the current address in a unique, normalized column, and retain a history row for each accepted change with the old value, new value, actor, timestamp, verification event, and reason code. Do not use the email as a ledger key, an order key, or a device identity. Case folding and Unicode normalization need a documented policy; your email provider's comparison rules are not a substitute for one.
The change itself should be a state machine: requested, pending-old-proof, pending-new-proof, committed, or expired. A single-use token binds both addresses and an intent ID. A retry of the same intent returns the same result, which is the exactly-once mindset applied to an HTTP workflow. If the user abandons it, expiration leaves the old address active rather than creating a half-linked identity.
How should changing an email address preserve continuity without a new account?
Require recent authentication and step-up proof proportional to risk. In our marketplace scenario, a familiar device with a low risk score can see a normal confirmation flow; a new fingerprint, impossible travel, or a payout-related session should require the old address plus the new address, and may delay high-value actions. The security boundary is the session, not the text field. That distinction matters during support escalations: an agent can correct a profile attribute, but cannot silently transfer authenticated authority without leaving a reviewable event.
A practical sequence is:
- Create an idempotent change intent attached to
account_id, with a five-minute expiry. - Send a confirmation to the current address and a separate verification to the proposed address.
- Commit both proofs in one database transaction, append an audit event, and rotate recovery material.
- Revoke sessions that were not recently stepped up, while preserving the session that completed the proof when policy allows.
Do not reveal whether an address belongs to an account in the request response. Return a generic acknowledgement, rate-limit attempts per account and network, and log delivery outcomes without putting tokens or full addresses into application logs. OWASP's Authentication Cheat Sheet also recommends reauthentication after high-risk changes and careful session invalidation.
Here is the core transaction boundary in Go. The interfaces are deliberately boring so the same policy can sit behind any HTTP router or storage engine.
type ChangeIntent struct {
ID, AccountID, OldEmail, NewEmail string
ExpiresAt time.Time
}
type AccountStore interface {
FindByID(ctx context.Context, id string) (Account, error)
CommitEmailChange(ctx context.Context, intent ChangeIntent, oldProof, newProof string) error
AppendAudit(ctx context.Context, event AuditEvent) error
}
func CompleteEmailChange(ctx context.Context, s AccountStore, intent ChangeIntent, oldProof, newProof string, now time.Time) error {
if now.After(intent.ExpiresAt) {
return ErrExpiredIntent
}
if oldProof == "" || newProof == "" {
return ErrMissingProof
}
if err := s.CommitEmailChange(ctx, intent, oldProof, newProof); err != nil {
return err
}
return s.AppendAudit(ctx, AuditEvent{
Action: "email_change_committed",
AccountID: intent.AccountID,
IntentID: intent.ID,
At: now,
})
}
The store must enforce uniqueness for the normalized new address and make intent.ID idempotent. In production I would put the audit append in the same transaction or an outbox transaction; otherwise a successful address change without a durable event leaves compliance and customer support guessing.
Where does the security-versus-friction trade-off land?
There is no universal number of prompts. Choose a policy by action sensitivity and evidence quality, then measure completion, takeover attempts, support reversals, and time-to-reconcile. A useful decision table looks like this:
| Situation | Proof and session action | Why |
|---|---|---|
| Recent MFA, known device, no payout pending | Verify new address; keep current session | Low friction for low-risk continuity |
| New device fingerprint or recovery signal | Verify old and new addresses; revoke other sessions | Raises the bar where takeover probability is higher |
| Address already claimed or sanctions review open | Stop and route to manual review | Prevents identity merges that ledger controls cannot unwind |
| Lost access to old address | Recovery process with stronger independent evidence | Email alone cannot prove account ownership |
The catch is operational: a two-proof flow can strand legitimate users whose old mailbox is gone. It is not suitable when your service has no independent recovery evidence or human review capacity; in that case, keep the old address and ask for recovery before offering a change. Your mileage may vary by jurisdiction, especially where retention and notification rules differ.
Rollout, reconciliation, and tests
Ship the state machine behind a feature flag. Backfill immutable IDs before enabling writes, then run a uniqueness report for normalized addresses and quarantine collisions. Test duplicate requests, expired tokens, replayed proofs, concurrent changes, mail delivery delays, and a database retry after commit. A property test should assert that every order, ledger entry, and dispute still resolves to exactly one account ID after any sequence of accepted address changes.
Watch metrics by risk band, not only globally: verification completion, session revocation rate, duplicate-intent rate, and manual-recovery queue age. Sample audit events for actor, reason, and correlation ID, and redact token material. The migration is complete only when reconciliation shows no new account rows created by an address edit and support can explain every exception from the event history.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://www.rfc-editor.org/rfc/rfc5321
- https://www.rfc-editor.org/rfc/rfc6819
Top comments (0)