Email Confirmation Authentication Implementation for Websites
Email confirmation authentication is a scheme where the user enters an email address, receives a message with a link or code, and completes login via that link. It's a passwordless authentication variant with an explicit ownership verification step.
Differs from Magic Link in that email verification can be a separate step during traditional registration—confirming the email exists and is accessible.
Two Usage Scenarios
Scenario 1: Verification on Registration User registers with password → receives email → confirms email → gains access to features.
Scenario 2: Passwordless Login via Email User enters email → receives email with link → clicks it → authenticated. No password at all.
Email Verification on Registration (Laravel)
// Model implements MustVerifyEmail
class User extends Authenticatable implements MustVerifyEmail
{
// ...
}
// routes/auth.php
Route::get('/email/verify/{id}/{hash}', [VerifyEmailController::class, '__invoke'])
->middleware(['auth', 'signed', 'throttle:6,1'])
->name('verification.verify');
Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware(['auth', 'throttle:6,1'])
->name('verification.send');
Custom email template:
class User extends Authenticatable implements MustVerifyEmail
{
public function sendEmailVerificationNotification(): void
{
$this->notify(new CustomVerifyEmailNotification());
}
}
Signed URL
Laravel generates a signed URL expiring in 60 minutes:
$url = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
The signature is HMAC-SHA256 with APP_KEY. Attempts to forge or modify parameters return 403.
OTP Code Instead of Link
Some projects prefer a 6-digit code instead of a link—more convenient when opening email on a different device:
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
Cache::put(
"email_verification:{$user->id}",
hash('sha256', $code),
now()->addMinutes(10)
);
// Code verification
public function verify(Request $request): JsonResponse
{
$stored = Cache::get("email_verification:{$user->id}");
if (!$stored || !hash_equals($stored, hash('sha256', $request->code))) {
return response()->json(['message' => 'Invalid or expired code'], 422);
}
$user->markEmailAsVerified();
Cache::forget("email_verification:{$user->id}");
return response()->json(['message' => 'Email confirmed']);
}
Resend and Spam Protection
// Cooldown between resend requests
RateLimiter::for('email-verification', function (Request $request) {
return Limit::perMinutes(5, 1)->by($request->user()->id);
});
Frontend shows countdown until resend is possible—typically 60 seconds.
Expiration and Invalid Links
If user clicks an expired link—redirect to a page offering to request a new email. Don't show a generic 403 without explanation.
Email Change
Changing email doesn't happen immediately—confirmation is sent to the new address first. Only after confirmation is the email updated. This prevents account takeover via email change.
// pending_email in users table or separate email_changes table
$user->update(['pending_email' => $newEmail]);
// Send verification to $newEmail
// On confirmation: $user->update(['email' => $newEmail, 'pending_email' => null])
Work Timeline
| Stage | Time |
|---|---|
| Registration verification (standard) | 1 day |
| Custom email templates | 0.5 day |
| OTP code instead of link | 1 day |
| Email change with confirmation | 1 day |
| Tests + edge cases | 1 day |
Basic verification on registration—1–2 days. Full flow with email change and OTP—4–5 days.







