Changing an email address can look like an ordinary profile update.
The user enters a new address, presses save, and the server updates the email field.
But an email address is often more than profile text. It may be used for login, account recovery, notifications, and identity verification.
Entering a new address therefore does not prove that the user controls it.
Until the new address is confirmed, the existing address remains canonical.
A Direct Field Update Creates a Dangerous Gap
$user->email = $request->email;$user->email_verified_at = null; $user->save(); $this->sendVerificationEmail( $user,);After this write, the system already treats the new address as belonging to the user.
But ownership has not been proven yet.
The old address may stop working for login or recovery before the new one becomes a trusted contact point.
The system has changed an important account attribute before it has enough evidence to do so.
I Store the New Address as a Pending Change
Current user address old@example.com | | user requests change vpending change new@example.com | | confirmation vnew canonical address new@example.comI separate two states.
The first is the confirmed canonical address of the account.
The second is an address the user has requested to move to.
Until the second address is confirmed, it does not replace the first and should not automatically receive all privileges of the canonical address.
The Change Request Has Its Own Lifecycle
Schema::create( 'email_change_requests', function (Blueprint $table): void { $table->id(); $table->foreignId('user_id'); $table->string( 'proposed_email', ); $table->string( 'token_hash', ); $table->timestamp(This is only a simplified example structure.
The important idea is that the pending change has its own state instead of leaving the user record half-transitioned.
The request has an owner, the proposed address, a bounded lifetime, and a way to tell whether it has already been consumed.
I Normalize the New Address Before Creating the Request
$email = mb_strtolower( trim( $request->string('email')->toString() ));Uniqueness must be checked using the same normalized representation used for persistence and comparison.
Otherwise one part of the system may treat two addresses as different while another treats them as equal.
I therefore normalize the proposed address before creating the change request.
I Perform an Early Uniqueness Check
if ( User::query() ->where( 'email', $email, ) ->whereKeyNot( $user->id, ) ->exists()) { throw ValidationException::withMessages([ 'email' => 'This email is already in use.', ]);}This check provides fast feedback.
If the address is already taken, there is no reason to send a confirmation email.
But this early check is not the final guarantee.
I Do Not Store the Confirmation Secret in Plain Text
$token = Str::random(64); $request = EmailChangeRequest::query() ->create([ 'user_id' => $user->id, 'proposed_email' => $email, 'token_hash' => hash( 'sha256', $token, ), The raw token is needed only to build the confirmation link.
In storage I keep only a value that can verify the supplied token without preserving the original secret.
A time-limited signed URL is another possible approach. In either case, confirmation should be bound to a specific request and have a finite lifetime.
An Email Change Link Should Not Remain Valid Forever
An email change request represents user intent at a particular point in time.
I do not want an old link to unexpectedly change an important account attribute months later.
Each request therefore has an expires_at, and expired confirmations are rejected.
A New Request Should Supersede the Previous One
Request 1 old@example.com-> first-new@example.com User changes their mind Request 2 old@example.com-> second-new@example.com Request 1If the user requests several different addresses, old links should not remain equally authoritative.
The new request should supersede the previous one or explicitly invalidate it.
Otherwise an older email could be confirmed after the user has already chosen a different address.
Confirmation Revalidates the State
DB::transaction( function () use ( $requestId, $token, ): void { $change = EmailChangeRequest::query() ->whereKey($requestId) ->lockForUpdate() ->firstOrFail(); if ( $change->consumed_at !== null || $change->expires_at->isPast() ) { throw InvalidEmailChange::requestUnavailable(); }At confirmation time, I no longer assume that conditions from request creation are still true.
The request itself is checked: whether it is still active, whether it has already been consumed, and whether the confirmation matches.
Then conditions that may have changed while the request was pending are validated again.
Uniqueness Must Be Checked Again at Confirmation Time
10:00 User A requests:new@example.com Address is available 10:05 Another account obtains:new@example.com 10:10 This is one of the most important parts of the flow.
The uniqueness check before sending the email only proves that the address was available at that moment.
Minutes or hours may pass before confirmation.
I therefore recheck uniqueness immediately before changing the canonical address.
The Database Keeps the Final Uniqueness Guarantee
Even a second application-level check cannot eliminate every concurrency race.
Two requests may both observe an address as available before either performs the write.
A database unique constraint therefore remains the final guarantee.
Application code provides clear behavior and a useful error. The database guarantees that conflicting state cannot actually be persisted.
I Apply the Canonical Email Change Atomically
$user = User::query() ->whereKey( $change->user_id, ) ->lockForUpdate() ->firstOrFail(); $emailIsTaken = User::query() ->where( 'email', $change->proposed_email, ) ->whereKeyNot( $user->id, ) ->exists();After successful confirmation, control of the new address has been proven.
It can therefore become canonical and be marked verified at the same time.
The request is consumed in the same transaction so reopening the same link cannot execute the change again.
The Existing Email Remains Canonical Until Confirmation
This is an important property of the design.
The user does not lose the current canonical address because of a typo in a proposed one.
If the email never arrives, the address was entered incorrectly, or the user simply changes their mind, the account remains in a normal state.
A failed email-change flow does not automatically become an account-access problem.
Reopening the Confirmation Link Should Not Repeat the Change
Confirmation should be a one-time operation.
After successful use, the request moves into a completed state.
Opening the same link again should not repeat the email change, duplicate side effects, or revive the old request.
The Current Session Should Remain Consistent During the Change
An email change should not accidentally leave the current authenticated session in an inconsistent state.
After successful confirmation, authenticated user state should reflect the new canonical address.
The exact reauthentication policy depends on product requirements, but it should be explicit rather than an accidental consequence of changing one field.
Confirmation Proves Control of the New Address
I keep different proofs separate.
The fact that a user is already signed in proves access to the current account session.
It does not prove ownership of a newly entered email address.
A confirmation delivered to the proposed address establishes that separate fact.
The Complete Safe Email Change Flow
1. User enters a new address 2. Address is normalized 3. Early uniqueness check runs 4. Canonical address does NOT change 5. A pending change request is created with an expiry 6. Confirmation is sent to the new address What I Verify in Tests
1. User submits a new address -> canonical address does not change -> pending request is created 2. Valid confirmation -> new address becomes canonical -> address becomes verified -> request becomes consumed 3. Expired confirmation -> address does not change 4. Invalid tokenWhat I Check in an Email Change Flow
My short checklist:
- Does the current email remain canonical until confirmation?
- Is the proposed email stored separately as a pending change?
- Does the request have a bounded lifetime?
- Is the confirmation secret protected rather than stored in plain text?
- Does a newer request supersede the previous one?
- Is uniqueness checked both at request creation and confirmation time?
- Does the database enforce uniqueness?
- Is the final switch applied atomically?
- Can confirmation be consumed only once?
- Does the user retain access if the new address is never confirmed?
Saving the Form and Changing the Account Are Not the Same Event
For an ordinary profile field, pressing save often can mean an immediate update.
An email address deserves a stronger boundary.
First, the user expresses an intent to move to a new address.
The system stores that intent separately, sends confirmation, limits its lifetime, and keeps the existing canonical address untouched.
Only after confirmation does it revalidate current state and atomically complete the change.
I do not replace an important account identifier until the system has proof that the user controls the proposed address.
A new email becomes canonical not when the form is saved, but when control of that address has been proven.



