In Laravel, what does EmailVerificationRequest check when a user opens the verification link, and what must happen when a verified user changes their email address?
answer
- a form request that only authorizes
- route id against the logged-in key
- sha1 of the current email
- fulfill() is idempotent, fires Verified
- markEmailAsUnverified joined the contract in 13
basics
~20 sEmailVerificationRequest authorizes only if the route's id equals the signed-in user's key and its hash equals sha1 of that user's current email; fulfill() then sets email_verified_at and fires Verified. On an email change, call markEmailAsUnverified() and resend the link.
solid answer
~40 sThe stock `VerifyEmail` notification builds a temporary signed URL to `verification.verify` with `id` (the user's key) and `hash` (`sha1` of `getEmailForVerification()`), valid for `auth.verification.expire` minutes, 60 by default. The route sits behind `auth` and `signed`. `EmailVerificationRequest` is a form request whose `authorize()` compares both parameters with `hash_equals` against the **authenticated** user; a mismatch makes it throw `AuthorizationException`, a 403. `fulfill()` calls `markEmailAsVerified()` and dispatches `Verified` only if the user was not already verified. The `sha1` is not a secret; the signature and the login are the protection, and the hash binds the link to one address. So when a verified user changes their email, set `email_verified_at` back to null with `markEmailAsUnverified()` (part of the `MustVerifyEmail` contract since Laravel 13) and call `sendEmailVerificationNotification()`; links sent for the old address then fail the hash check.
code
php · 23 lines<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProfileController extends Controller
{
public function update(Request $request)
{
$user = $request->user();
$user->fill($request->validate(['email' => 'required|email|unique:users,email,'.$user->id]));
$emailChanged = $user->isDirty('email');
$user->save();
if ($emailChanged) {
$user->markEmailAsUnverified();
$user->sendEmailVerificationNotification();
}
return back()->with('status', 'profile-updated');
}
}go deeper
Recall that the link carries the user's id and an email hash, sits behind auth and signed, and that fulfill() marks the email verified.
Explain the split of work between signed, auth and EmailVerificationRequest::authorize(), and why a mismatch returns 403.
Handle email changes correctly: markEmailAsUnverified(), a fresh link, and why old links die through the hash check; know the Laravel 13 contract change.
Decide which privileges an unverified or recently changed address should keep, and whether email changes need confirmation on the old address as well.
## How the link is built When a student registers on the e-learning site, the `VerifyEmail` notification's `verificationUrl()` calls: ```php URL::temporarySignedRoute( 'verification.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), ['id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification())] ); ``` Two things are worth noting: - The lifetime comes from `auth.verification.expire`, which is **not** in the skeleton's `config/auth.php`; the code falls back to **60 minutes**. Add the key yourself to change it. - The `hash` is a plain `sha1` of the email address. Anyone who knows the address can compute it, so it is **not** a secret. It exists to tie the link to one specific address. ## What protects the route The documented route is `GET /email/verify/{id}/{hash}`, named `verification.verify`, with the `auth` and `signed` middleware. Each layer has a job: | Layer | What it rejects | |---|---| | `signed` | a tampered URL or one past its expiry | | `auth` | a visitor who is not signed in (they are sent to log in first) | | `EmailVerificationRequest::authorize()` | a signed-in user who is not the one the link was made for, or whose email changed | ## What `EmailVerificationRequest` does `Illuminate\Foundation\Auth\EmailVerificationRequest` extends `FormRequest`, has an empty `rules()` array, and does all its work in `authorize()`: 1. `hash_equals((string) $this->user()->getKey(), (string) $this->route('id'))` must be true. 2. `hash_equals(sha1($this->user()->getEmailForVerification()), (string) $this->route('hash'))` must be true. 3. If either fails, `authorize()` returns false, the form request throws `AuthorizationException`, and the user sees a **403**. Then your handler calls `$request->fulfill()`, which: - does nothing if `hasVerifiedEmail()` is already true, so clicking the link twice is harmless and no second event fires; - otherwise calls `markEmailAsVerified()` (a `forceFill` of `email_verified_at` with the current timestamp, then `save()`) and dispatches `Illuminate\Auth\Events\Verified`. Because `authorize()` calls `$this->user()->getKey()`, the class assumes a signed-in user; that is another reason the route must carry `auth`. ## When the email address changes Suppose a verified instructor changes their address from their old university to a personal one. Nothing in the framework core resets verification for you; `email_verified_at` stays set, and the account would count as verified for an address nobody has confirmed. The profile-update code must: 1. Detect the change, for example with `$user->isDirty('email')` before saving. 2. Call `$user->markEmailAsUnverified()`, which sets `email_verified_at` to null and saves. 3. Call `$user->sendEmailVerificationNotification()` to mail a link for the new address. Any link still sitting in the old inbox now fails step 2 of `authorize()`, because its `hash` was computed from the old address, even though its signature is still valid until it expires. **Version note:** the `MustVerifyEmail` trait provides `markEmailAsUnverified()`, and Laravel 13 added it to the `Illuminate\Contracts\Auth\MustVerifyEmail` **interface**. A custom class that implements the contract without the trait must now define it. ## Expired links and resending - A link past its expiry fails in the `signed` middleware, which throws `InvalidSignatureException`, rendered as a **403** "Invalid signature." page; `EmailVerificationRequest` never runs. - The student recovers through the `verification.send` route, which calls `sendEmailVerificationNotification()` again and should be throttled. - To give links longer than an hour, add `'verification' => ['expire' => 120]` to `config/auth.php`; the notification reads `auth.verification.expire` in minutes. - Every resend creates a new signed URL, but older unexpired ones keep working as long as the address has not changed: there is no stored token to replace. ## Common misreadings - "The hash is a secret token." It is `sha1(email)`; the signature is the secret part. - "The link works from any browser." It needs the same user signed in. - "`signed` alone is enough." Without the id and hash checks, any signed-in user could reuse someone else's link inside its lifetime.
- Why is it acceptable that the hash segment is just sha1 of the email?The hash is not what makes the link unforgeable; the `signed` middleware checks an HMAC signature over the whole URL, including its expiry. The hash only binds the link to the address it was sent to, so after an email change the old link fails `authorize()` even though its signature still validates.
- What happens if a student clicks the verification link in a browser where they are not signed in?The `auth` middleware stops the request and redirects to the login route, remembering the intended URL. If the login flow redirects to the intended URL, the student comes back to the link; `signed` still validates if it has not expired, and `EmailVerificationRequest` compares the id and hash with the now-authenticated user.
The verification link is like a parcel collection slip: the depot stamp (the URL signature) proves the slip is genuine and still dated, but the clerk also checks that the person at the counter is the addressee and that the name on the slip still matches their ID. Change your name and the old slip is void, stamp or not.
saying these in an interview costs you the question
- The hash in the verification URL is a secret random token
- EmailVerificationRequest validates the URL signature itself
- Saving a new email on the model resets email_verified_at automatically
- Calling fulfill() twice dispatches the Verified event twice
- The verification link can be opened by anyone who receives it, signed in or not