Passwords, Reset and Verification

How the hashed cast stores a password, how reset tokens work, how email verification is wired, and how to throttle the login route.

SyntaxPassword::sendResetLink(array $credentials): string

Three jobs sit close together: storing a password safely, letting someone who forgot it set a new one, and proving an email address belongs to its owner.

Hashing on the model

The skeleton's App\Models\User casts password to hashed. Assigning $user->password = $plain runs it through Hash::make on the way to the database, and passes it through untouched if it is already a hash. The algorithm is bcrypt by default, with the work factor from config/hashing.php or BCRYPT_ROUNDS. Raise the factor and Auth::attempt rehashes each password as its owner logs in, unless rehash_on_login is false.

Reset tokens

The password broker does the reset work, reached through the Password facade. Password::sendResetLink(['email' => $email]) finds the user through the provider named in config('auth.passwords'), stores a hashed token in password_reset_tokens keyed by email, and mails a link. Password::reset() verifies the token, then calls your closure with the user and the new password. Both return a status slug. Compare it with Password::ResetLinkSent or Password::PasswordReset; those Laravel 13 constants are aliases for the older PasswordBroker::RESET_LINK_SENT and PasswordBroker::PASSWORD_RESET, which hold the same strings. expire (60 minutes) and throttle (60 seconds) live in config/auth.php; php artisan auth:clear-resets deletes dead rows.

Verification and throttling

Implement MustVerifyEmail on the user model and the Registered event mails a signed link; the verified middleware then blocks unverified users. Protect the login route with a named rate limiter and the throttle middleware.

Example

Example · php
<?php
// routes/auth.php
// Add require __DIR__.'/auth.php'; to routes/web.php, or point
// bootstrap/app.php at this file as well.

declare(strict_types=1);

use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;

// 1. The "I forgot" form.
Route::get('/forgot-password', fn () => view('auth.forgot-password'))
    ->middleware('guest')
    ->name('password.request');

// 2. Send the link. The broker stores a hashed token in
//    password_reset_tokens and mails Illuminate's ResetPassword
//    notification.
Route::post('/forgot-password', function (Request $request) {
    $request->validate(['email' => ['required', 'email']]);

    $status = Password::sendResetLink($request->only('email'));

    // Password::ResetLinkSent is the string 'passwords.sent'.
    return $status === Password::ResetLinkSent
        ? back()->with('status', __($status))
        : back()->withErrors(['email' => __($status)]);
})->middleware(['guest', 'throttle:6,1'])->name('password.email');

// 3. The form the emailed link opens. The token travels in the URL.
Route::get('/reset-password/{token}', fn (string $token) => view('auth.reset-password', ['token' => $token]))
    ->middleware('guest')
    ->name('password.reset');

// 4. Verify the token and set the new password.
Route::post('/reset-password', function (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 $user, string $password): void {
            // Called only when the token checks out.
            $user->forceFill(['password' => Hash::make($password)])
                ->setRememberToken(Str::random(60));

            $user->save();

            event(new PasswordReset($user));
        },
    );

    return $status === Password::PasswordReset
        ? redirect()->route('login')->with('status', __($status))
        : back()->withErrors(['email' => __($status)]);
})->middleware('guest')->name('password.update');

When to use it

  • Adding forgotten-password links to an application that only has login
  • Requiring a verified email address before a user can reach the dashboard
  • Raising the bcrypt work factor without logging everyone out
  • Stopping credential-stuffing attempts against the login route

More examples

The user model: hashed cast and verification

The hashed cast is why User::create(['password' => 'secret']) stores a bcrypt hash and why calling Hash::make yourself is harmless: the cast checks Hash::isHashed first and passes an existing hash through. It does verify the algorithm, so assigning a hash made with a different driver than config/hashing.php throws a RuntimeException reading "Could not verify the hashed value's configuration." The check runs on the way in only, so a mismatched hash already sitting in the column is read back without complaint; it is Hash::check that refuses it later. Adding MustVerifyEmail changes behaviour on its own: the framework's SendEmailVerificationNotification listener mails a signed link whenever the Registered event fires.

Example · php
<?php
// app/Models/User.php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

// Laravel 13's skeleton uses attributes here. The $fillable and
// $hidden properties are the longhand and still work:
//   protected $fillable = ['name', 'email', 'password'];
//   protected $hidden = ['password', 'remember_token'];
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements MustVerifyEmail
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            // Assigning a plain string hashes it on the way in.
            'password' => 'hashed',
        ];
    }
}

The three verification routes

Three route names are load-bearing here, because middleware and the mailed link resolve them by name rather than by URL. The signed middleware is what makes the link tamper-proof: it is signed with APP_KEY, so changing the key invalidates every link already in someone's inbox. Use this when you register users yourself; a starter kit has already written these routes through Fortify.

Example · php
<?php
// routes/auth.php (continued)

use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// The name must be verification.notice: the 'verified' middleware
// redirects unverified users to that exact route name.
Route::get('/email/verify', fn () => view('auth.verify-email'))
    ->middleware('auth')
    ->name('verification.notice');

// EmailVerificationRequest is a form request that ships with Laravel.
// It validates the {id} and {hash} segments; 'signed' middleware checks
// the signature on the URL. fulfill() marks the address verified and
// dispatches Illuminate\Auth\Events\Verified.
Route::get('/email/verify/{id}/{hash}', function (EmailVerificationRequest $request) {
    $request->fulfill();

    return redirect('/dashboard');
})->middleware(['auth', 'signed'])->name('verification.verify');

Route::post('/email/verification-notification', function (Request $request) {
    $request->user()->sendEmailVerificationNotification();

    return back()->with('status', 'verification-link-sent');
})->middleware(['auth', 'throttle:6,1'])->name('verification.send');

// Then gate the rest of the application behind it:
Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('/dashboard', fn () => view('dashboard'))->name('dashboard');
});

Rate limiting the login route

Without this, a login route accepts passwords as fast as a script can post them. Keying by email plus IP is the shape the starter kits use: it slows an attacker working through one account without locking out a whole office behind one address. Exceeding a limit returns 429 with Retry-After and X-RateLimit headers; the counters live in the cache store, so a cache:clear resets them.

Example · php
<?php
// app/Providers/AppServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // A named limiter. Returning an array applies both limits in order.
        RateLimiter::for('login', function (Request $request) {
            return [
                Limit::perMinute(500),
                Limit::perMinute(5)->by($request->input('email').'|'.$request->ip()),
            ];
        });
    }
}

// routes/web.php
//
// Route::post('/login', [LoginController::class, 'store'])
//     ->middleware(['guest', 'throttle:login']);
//
// The inline longhand, when a named limiter is more than you need.
// Five attempts per one minute, keyed by IP:
//
//     ->middleware('throttle:5,1');

Discussion

  • Be the first to comment on this lesson.