DEV Community

mT41vB6
mT41vB6

Posted on

Email Change Workflow: 3 Steps to Request, Confirm, and Preserve Account Continuity

The hard constraint is account continuity: an email address is a mutable contact point, while the account needs a stable identity. Short answer: keep an immutable internal user ID, require a fresh authentication check before a change, send confirmation to the new address, notify the old address, and do not move login or recovery to the new address until confirmation succeeds.

This matters even more when a developer tool supports Google and GitHub sign-in. A social identity, a password credential, an email address, and an account are related records; they are not interchangeable identifiers. Treating the email string as the join key can split one person into two accounts or let an unconfirmed address become a recovery channel.

How should an email change workflow request, confirm, and preserve account continuity?

Model the operation as a small state machine: request, confirm, then commit. The request is accepted only after recent reauthentication. It records the proposed address against the stable user ID, but the current address remains authoritative for sign-in, notifications, and recovery. The confirmation step proves control of the proposed mailbox with a single-use, expiring token. The commit changes the verified contact point in one transaction and invalidates every outstanding token for that request.

Keep the states explicit.

State Login and recovery email Allowed transition Security action
idle Current verified email Request change Require recent authentication
pending Current verified email Confirm or cancel Notify the old address; rate-limit retries
committed New verified email Start a later request Revoke pending tokens and record the event
expired Current verified email Start again Reject the old token without revealing account data

A pending address must not quietly become a login alias. That rule closes a nasty edge case: a user mistypes alex@example.com as an address controlled by someone else, then an eager identity layer begins accepting it before mailbox ownership has been proved. The old address should receive a security notification, but a link in that message should lead to an authenticated review or cancellation flow rather than disclose sensitive profile data. OWASP's authentication guidance supports requiring current credentials for sensitive account changes and notifying the current address when an email changes.

No silent promotion.

Use one database transaction for the final move. A uniqueness constraint on normalized verified addresses is still necessary, yet normalization should be conservative: trim surrounding whitespace and apply the domain rules your system documents; don't invent provider-specific transformations that merge distinct mailboxes. Your mileage may vary for internationalized addresses, so settle that policy before migration and test it with production-shaped data.

Bind confirmation to one intent, not just one mailbox

A confirmation token needs to identify the user, the pending change record, and the exact proposed address. Store only a hash of the token. Give the record an expiration time, a consumed time, and a monotonically increasing version so a second request cancels the first. This isn't ceremony. Without intent binding, two open browser tabs can confirm requests out of order and leave the audit trail claiming the wrong transition.

The service below shows the boundary. It deliberately uses generic repository and mail interfaces; transport details belong behind those interfaces, and every outward response stays non-enumerating.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import secrets


@dataclass(frozen=True)
class PendingEmailChange:
    id: str
    user_id: str
    proposed_email: str
    token_hash: str
    expires_at: datetime
    version: int


def request_email_change(user, proposed_email, recent_auth, repo, mailer):
    if not recent_auth.is_fresh_for(user.id):
        raise PermissionError("Recent authentication required")

    normalized = proposed_email.strip()
    raw_token = secrets.token_urlsafe(32)
    pending = repo.replace_pending_change(
        user_id=user.id,
        proposed_email=normalized,
        token_hash=sha256(raw_token.encode()).hexdigest(),
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=30),
    )
    mailer.send_confirmation(normalized, pending.id, raw_token)
    mailer.send_change_notice(user.verified_email, pending.id)
    return {"status": "pending"}


def confirm_email_change(change_id, raw_token, repo, sessions, clock):
    token_hash = sha256(raw_token.encode()).hexdigest()
    with repo.transaction():
        pending = repo.lock_pending_change(change_id)
        if pending is None or pending.expires_at <= clock.now():
            raise ValueError("Invalid or expired confirmation")
        if not secrets.compare_digest(pending.token_hash, token_hash):
            raise ValueError("Invalid or expired confirmation")

        repo.commit_verified_email(
            user_id=pending.user_id,
            proposed_email=pending.proposed_email,
            expected_version=pending.version,
        )
        repo.consume_all_email_change_tokens(pending.user_id)
        sessions.apply_sensitive_change_policy(pending.user_id)
    return {"status": "confirmed"}
Enter fullscreen mode Exit fullscreen mode

The 30-minute lifetime is an example policy, not a standard. Pick it alongside resend limits, delivery latency, and support procedures. I've found the useful design question isn't "how long feels secure?" but "what happens when the first message arrives after the user requested a second one?" The version check makes the answer deterministic: only the newest live intent can commit.

Do not log raw tokens, confirmation URLs, or full mailbox values. Log a request ID, stable user ID, transition, reason code, timestamp, and a redacted or separately protected address reference. Delivery metrics should distinguish accepted, bounced, delayed, expired, canceled, and confirmed events; otherwise an OTP or email delivery gap looks like user abandonment.

How can social sign-in preserve the same account during recovery?

Google and GitHub sign-in should link through the issuer and provider subject identifier, stored against the internal user ID. The email claim can help present choices, but it should not silently link accounts. Provider email claims can change, and the same human may expose different addresses through different providers. Account continuity comes from durable credential links plus an explicit, authenticated linking ceremony.

Keep those links stable.

Consider a user who created the developer-tool account with GitHub, later linked Google, and now changes the product's contact email. Confirming the new mailbox should update the product's verified contact record; it should not rewrite either social credential identifier. If the user loses the old mailbox, either linked social credential can still authenticate the same internal account. If they lose both social credentials, the recovery policy must define what independent evidence support can accept. Email possession alone may be too weak for a high-impact account with deployment secrets or billing authority.

This is the catch: a strict policy can strand a legitimate user who has lost the old mailbox and every linked provider. A support-assisted recovery path may preserve access, but it raises social-engineering risk and compliance workload. It is not suitable when the team cannot consistently verify ownership or protect support tooling; in that case, require an additional recovery factor before allowing the email change. Conversely, don't force a social-provider relink just because the contact email changed. That couples unrelated credentials and creates avoidable lockouts.

Session handling needs an explicit decision too. For a low-risk profile, rotating the current session and revoking other sessions after confirmation may be enough. For an administrative account, require step-up authentication again before sensitive actions. Whatever policy you choose, apply it after the transactional commit so retries cannot produce half-updated credentials and sessions.

Test failure modes before comparing implementations

Start with invariants, then compare build-versus-buy options. The stable user ID never changes. An unverified proposed address never becomes a login or recovery identifier. At most one pending request can commit. A consumed, expired, canceled, or superseded token cannot commit. Every successful change produces an old-address notification and an auditable event without exposing a secret.

Exercise races, not only happy paths: request A, request B, confirm A; confirm the same token twice; confirm while another transaction claims the address; resend after expiration; unlink one social provider during a pending change; and attempt recovery while the commit is in flight. Run mail delivery through a test sink, but verify the rendered link, host allowlist, token redaction, and resend behavior as separate assertions. A 409 on an address collision and a generic invalid-token response are useful contract examples; the public response still must not reveal which account owns an address.

If the team evaluates Auth0, Clerk, and Supabase, inspect each current implementation against those same invariants instead of treating product presence as evidence of fit. Configuration models and extension boundaries can change, so I'm not sure a durable product-level comparison can be made without pinning the exact plan, deployment mode, and documentation version. The decision record should capture who owns reauthentication, pending-state storage, old-address notification, token invalidation, social-identity linking, audit retention, export, and support recovery. Stick with an in-house workflow when product constraints require custom recovery evidence and the team can operate the security controls; prefer a managed implementation when its documented state transitions match the policy and reducing operational ownership matters more than customization.

Roll out without breaking existing account continuity

First, inventory places where email is used as a primary key, foreign key, session claim, cache key, or social-account join. Introduce an immutable user ID before changing the workflow. Backfill credential links, then reject new email-based joins.

Next, deploy the pending-change table and notifications behind an internal flag. Run migration checks for duplicate normalized addresses and users whose only recovery path is the mailbox being changed. Enable the flow for staff, then a small cohort, while watching confirmation latency, bounce categories, resend volume, cancellations, recovery escalations, and account-link conflicts. Roll back by disabling new requests, not by reversing already confirmed addresses.

Finally, document the support decision tree and rehearse it. The code path is compact; the real system includes mail delivery, session policy, identity links, audit access, privacy retention, and a human recovery boundary. Preserve that boundary and an email change remains a contact update instead of becoming an account migration.

References

Top comments (0)