Under the Hood

Password Reset Flow - From Email to Database Update

Learn how Laravel's password reset flow works from start to finish. Understand token generation, email delivery, validation, and secure password updates in Laravel 12.

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

|
12-Dec-2025
7 min read
Password Reset Flow - From Email to Database Update

"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:

  1. Request Phase: User submits email address
  2. Token Generation: Laravel creates secure reset token
  3. Email Delivery: Reset link sent to user
  4. 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.


Article Tags

laravel password reset password reset flow laravel forgot password password reset token laravel reset password email

About the Author

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

Founder & CEO at CentoSquare | Creator of NeedLaravelSite | Helping Businesses Grow with Cutting-Edge Web, Mobile & Marketing Solutions | Building Innovative Products