DEV Community

grahamprice3746
grahamprice3746

Posted on

Email Identity Continuity Explained — Address Changes Without Duplicate Accounts

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:

  1. Create an idempotent change intent attached to account_id, with a five-minute expiry.
  2. Send a confirmation to the current address and a separate verification to the proposed address.
  3. Commit both proofs in one database transaction, append an audit event, and rotate recovery material.
  4. 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,
    })
}
Enter fullscreen mode Exit fullscreen mode

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

Sources

Top comments (0)