"Forgot your password?" Those three words trigger one of the most critical security flows in your Laravel application. Behind that simple form lies a sophisticated process involving token generation, email delivery, cryptographic validation, and secure database updates. Let's trace the complete journey from forgotten password to successful reset.
The Complete Password Reset Journey
The password reset process involves four distinct phases:
- Request Phase: User submits email address
- Token Generation: Laravel creates secure reset token
- Email Delivery: Reset link sent to user
- Password Update: User sets new password
Let's explore each phase in detail.
Phase 1: Password Reset Request
The Request Form
// Forgot password form
<form method="POST" action="{{ route('password.email') }}">
@csrf
<input type="email" name="email" required>
<button type="submit">Send Reset Link</button>
</form>
Controller Processing
use Illuminate\Support\Facades\Password;
public function sendResetLink(Request $request)
{
$request->validate(['email' => 'required|email']);
// Send password reset link
$status = Password::sendResetLink(
$request->only('email')
);
return $status === Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
Phase 2: Token Generation and Storage
When Password::sendResetLink() is called, Laravel performs several operations:
Step 1: User Lookup
// Behind the scenes
$user = User::where('email', $request->email)->first();
if (!$user) {
// Return generic error (prevents user enumeration)
return Password::INVALID_USER;
}
Laravel doesn't reveal whether an email exists in the database—this prevents attackers from discovering valid user accounts.
Step 2: Token Generation
// Behind the scenes
$token = Str::random(64);
Laravel generates a cryptographically secure 64-character random string. This becomes the password reset token.
Step 3: Token Hashing
Critical security step—Laravel NEVER stores plain-text tokens:
// Behind the scenes
$hashedToken = hash_hmac('sha256', $token, config('app.key'));
The token is hashed using HMAC-SHA256 with your application key. Only the hash is stored in the database.
Step 4: Database Storage
// Insert into password_reset_tokens table
DB::table('password_reset_tokens')->updateOrInsert(
['email' => $user->email],
[
'email' => $user->email,
'token' => $hashedToken,
'created_at' => now(),
]
);
The password_reset_tokens table structure:
// Migration
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Key point: Only ONE reset token per email address. New requests overwrite old tokens.
Phase 3: Email Delivery
Sending the Reset Email
// Behind the scenes
$user->sendPasswordResetNotification($token);
Laravel sends an email with the reset link:
// The notification
class ResetPasswordNotification extends Notification
{
public function toMail($notifiable)
{
$url = url(route('password.reset', [
'token' => $this->token,
'email' => $notifiable->email,
], false));
return (new MailMessage)
->subject('Reset Password Notification')
->line('You are receiving this email because we received a password reset request.')
->action('Reset Password', $url)
->line('This link will expire in 60 minutes.')
->line('If you did not request a password reset, no action is required.');
}
}
The Reset URL Structure
https://yourapp.com/reset-password?token=abc123...&email=user@example.com
The URL contains:
- token: Plain-text 64-character token (not the hash!)
- email: User's email address
Phase 4: Token Validation and Password Reset
The Reset Form
// Password reset form
<form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ $token }}">
<input type="email" name="email" value="{{ $email }}" readonly>
<input type="password" name="password" required>
<input type="password" name="password_confirmation" required>
<button type="submit">Reset Password</button>
</form>
Validation Process
public function reset(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => 'required|confirmed|min:8',
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user, $password) {
$user->password = Hash::make($password);
$user->save();
event(new PasswordReset($user));
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
Behind the Scenes Validation
When Password::reset() is called:
Step 1: User Verification
// Behind the scenes
$user = User::where('email', $request->email)->first();
if (!$user) {
return Password::INVALID_USER;
}
Step 2: Token Hash Comparison
// Behind the scenes
$hashedToken = hash_hmac('sha256', $request->token, config('app.key'));
$record = DB::table('password_reset_tokens')
->where('email', $request->email)
->first();
if (!$record || !hash_equals($record->token, $hashedToken)) {
return Password::INVALID_TOKEN;
}
Laravel hashes the provided token and compares it with the stored hash using hash_equals() to prevent timing attacks.
Step 3: Token Expiration Check
// Behind the scenes (default: 60 minutes)
if (Carbon::parse($record->created_at)->addMinutes(60)->isPast()) {
return Password::INVALID_TOKEN;
}
Tokens expire after 60 minutes by default:
// config/auth.php
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60, // Minutes
'throttle' => 60, // Seconds between requests
],
],
Step 4: Password Update
// Behind the scenes
$user->password = Hash::make($newPassword);
$user->save();
The password is hashed using bcrypt or Argon2 before storage.
Step 5: Token Deletion
// Behind the scenes - invalidate used token
DB::table('password_reset_tokens')
->where('email', $user->email)
->delete();
Once used, the token is deleted immediately. It cannot be reused.
Security Features
1. Token Hashing
// Plain token sent via email: abc123...
// Hashed token in database: 5f4d...
// Even if database is compromised, tokens cannot be used
2. Rate Limiting
// config/auth.php
'throttle' => 60, // Seconds between reset requests
// Behind the scenes
if ($this->tooManyAttempts($email)) {
return Password::RESET_THROTTLED;
}
Users can only request password resets once per minute.
3. Single Use Tokens
// Token is deleted after successful reset
// Cannot be used twice
4. Time-Limited Tokens
// Default: 60 minutes
'expire' => 60,
// After expiration, token becomes invalid
5. No User Enumeration
// Same response whether email exists or not
return back()->with('status', 'Password reset link sent!');
// Attackers cannot discover valid email addresses
Customizing Password Reset
Custom Token Expiration
// config/auth.php
'passwords' => [
'users' => [
'expire' => 120, // 2 hours
],
],
Custom Reset Email
// In User model
public function sendPasswordResetNotification($token)
{
$this->notify(new CustomResetPasswordNotification($token));
}
Custom Validation Rules
$request->validate([
'password' => [
'required',
'confirmed',
'min:12',
'regex:/[a-z]/', // Lowercase
'regex:/[A-Z]/', // Uppercase
'regex:/[0-9]/', // Number
'regex:/[@$!%*#?&]/', // Special char
],
]);
Common Issues and Solutions
Issue 1: Reset Link Not Working
// Problem: Token doesn't match
// Solution: Check APP_KEY hasn't changed
// Token hashing uses APP_KEY
// Changing APP_KEY invalidates all tokens
Issue 2: Email Not Sending
// Check mail configuration
// .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=your_username
MAIL_PASSWORD=your_password
Issue 3: Token Already Used
// Tokens are single-use
// Request new token if needed
Issue 4: Token Expired
// Default: 60 minutes
// User must request new token
// Consider increasing expiration for your use case
Best Practices
1. Use HTTPS
// Force HTTPS for password reset routes
Route::middleware('https')->group(function () {
Route::post('/forgot-password', [PasswordController::class, 'sendLink']);
Route::post('/reset-password', [PasswordController::class, 'reset']);
});
2. Log Password Resets
// In the password reset callback
function ($user, $password) {
$user->password = Hash::make($password);
$user->save();
// Log the reset
Log::info('Password reset', [
'user_id' => $user->id,
'ip' => request()->ip(),
'timestamp' => now(),
]);
event(new PasswordReset($user));
}
3. Notify User of Password Change
// Send confirmation email
$user->notify(new PasswordChangedNotification());
4. Invalidate Sessions
// Force logout from all devices after password reset
function ($user, $password) {
$user->password = Hash::make($password);
$user->setRememberToken(Str::random(60)); // Invalidate remember me
$user->save();
// Clear all sessions for this user
DB::table('sessions')
->where('user_id', $user->id)
->delete();
}
Performance Considerations
The password reset flow is relatively lightweight:
// Request Phase:
// - 1 SELECT query (user lookup)
// - 1 INSERT/UPDATE query (token storage)
// - 1 email send operation
// Average: 100-500ms (mostly email sending)
// Reset Phase:
// - 1 SELECT query (user lookup)
// - 1 SELECT query (token validation)
// - 1 UPDATE query (password update)
// - 1 DELETE query (token removal)
// Average: 50-150ms
Conclusion
Laravel's password reset flow is a masterpiece of security engineering. From cryptographically secure token generation to HMAC hashing, rate limiting, and single-use tokens, every step is designed to protect user accounts while maintaining a smooth user experience.
Understanding this flow helps you customize the process, debug issues, and implement additional security measures when needed. The next time a user clicks "Forgot Password," you'll know the sophisticated machinery working behind the scenes.
Need help implementing custom password reset flows or securing your Laravel authentication? At NeedLaravelSite, we specialize in Laravel security implementations and application migrations from version 7 to 12. From custom password policies to advanced authentication systems, we build secure, user-friendly solutions.