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.
Password::sendResetLink(array $credentials): stringThree 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
<?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.
<?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.
<?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.
<?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