Authentication is not a single login controller. It is a lifecycle covering registration, login, sessions, email verification, password reset, multi-factor authentication, sensitive account changes and logout. A Laravel application can use the right framework feature at every individual step and still be vulnerable as a complete system.
An account is secure only when identity, session and authorisation remain aligned during every state transition.
This guide examines the boundaries that frequently drift apart in production Laravel applications. It extends 10 common security mistakes in Laravel projects with a focused review of the complete account lifecycle.
Document the authentication model first
Five questions need explicit answers before implementation:
- Which guards authenticate the browser, API, administration and internal services?
- Which credentials are sent automatically by the browser and therefore need CSRF protection?
- Which actions require only an active session and which require recently confirmed identity?
- When are sessions, API tokens, remember-me tokens and recovery codes revoked?
- Which channels can a suspended or unverified user still reach?
Laravel separates guards and user providers. That technical distinction is useful but does not replace a business state machine. An account may be pending, active, suspended or recovery_locked. The same rule must apply to web routes, APIs, broadcast channels, queued actions and administrative impersonation.
A route group makes some of those assumptions visible:
Route::middleware(['auth', 'auth.session', 'verified', EnsureUserIsActive::class])
->group(function (): void {
Route::get('/dashboard', DashboardController::class);
Route::post('/exports', CreateExportController::class)
->middleware('password.confirm');
});
Recommendation: Build a small matrix of channel, guard, account state and additional confirmation. Middleware attached only in routes/web.php does not automatically protect API routes or broadcast channels.
Protect login against enumeration and brute force
Different responses such as “email unknown”, “password incorrect” or noticeably different response times help attackers identify valid accounts. Public feedback should remain independent of which part of authentication failed.
Rate limiting should consider both account and source address. Limiting only the IP punishes shared networks; limiting only the email allows distributed attacks or deliberate account lockout.
RateLimiter::for('login', function (Request $request): array {
$email = Str::lower((string) $request->input('email'));
return [
Limit::perMinute(60)->by('ip:'.$request->ip()),
Limit::perMinute(10)->by('account:'.hash('sha256', $email)),
Limit::perMinute(5)->by('pair:'.hash('sha256', $email.'|'.$request->ip())),
];
});
Regenerate the session identifier after a successful login:
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
Logs should reveal abusive patterns without recording passwords, complete tokens or unnecessary personal data. CAPTCHA may add another layer during active abuse, but it does not replace server-side limits.
Recommendation: Test valid and invalid accounts, distributed addresses, email casing and concurrent login attempts. Verify that SSO and API login cannot bypass the same protection.
Configure session cookies deliberately
For an ordinary Laravel application served exclusively through HTTPS, Secure, HttpOnly and an explicit SameSite=Lax are a dependable starting point:
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
HttpOnly prevents direct JavaScript access but does not make a session immune to XSS. Secure restricts transport to TLS. SameSite=Lax reduces cross-site requests carrying cookies automatically, but it does not replace Laravel's CSRF verification for state-changing requests.
SameSite=None is not a universal fix for separate frontends. Modern browsers accept it only together with Secure; the cookie may then be sent on cross-site requests and expands the CSRF attack surface. If an embedded workflow genuinely needs it, use separate cookies where possible, narrow origins and complete CSRF protection.
The difference between site and origin matters. Subdomains can be same-site while remaining different origins. An abandoned or externally controlled subdomain may therefore influence cookie and CORS decisions.
Recommendation: Inspect the actual Set-Cookie headers delivered in production. Tests against configuration values alone do not reveal incorrect proxy, domain or TLS behaviour.
Keep CSRF and HTTP methods aligned
Browsers send matching session cookies automatically. Cookie-authenticated browser POST, PUT, PATCH and DELETE requests therefore need CSRF protection. Laravel's web middleware group protects these methods by default. In Laravel 13, PreventRequestForgery accepts a demonstrably same-origin request through Sec-Fetch-Site: same-origin; otherwise it falls back to the session token. Older browsers without that header therefore continue through token verification. A GET route should not place an order, remove an account or change a permission.
// Unsafe: a link or external image can trigger the action.
Route::get('/projects/{project}/archive', ArchiveProjectController::class);
// Better: state-changing method, CSRF and authorisation.
Route::delete('/projects/{project}', DeleteProjectController::class)
->middleware(['auth', 'can:delete,project']);
Token-based APIs make a different assumption. If a token is sent only through an explicit Authorization header, a hostile website cannot attach it automatically. The moment the same API also accepts a session cookie, that path must be treated like a browser application and protected against CSRF.
Recommendation: Inventory all write routes and review method, guard, CSRF, authorisation and rate limit together. Do not treat placement in web.php or api.php as a security boundary.
Rotate and terminate sessions correctly
Calling session()->regenerate() after login prevents session fixation. Logout should clear authentication, invalidate session state and replace the CSRF token together:
public function destroy(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
A password change does not necessarily remove every other device from the account. Laravel can invalidate password-backed sessions on other devices using auth.session and Auth::logoutOtherDevices(): the middleware compares a password hash stored in the session with the user's current hash. Personal API tokens and other guards are not covered automatically. The product must decide when each credential must be revoked, such as after a password reset, detected account takeover or removal of an MFA factor.
Long-running workers and separate token stores belong in the test as well. A successful database update does not prove that every active credential path was revoked immediately.
Recommendation: Define revocation rules for sessions, remember-me credentials, personal API tokens, OAuth tokens, magic links and recovery codes. Test every credential again after each security event.
Treat password reset as a high-risk flow
Password reset effectively acts as an alternative login method. It needs generic responses, rate limits, short-lived single-use tokens and an explicit decision about existing sessions.
Route::post('/forgot-password', SendPasswordResetLinkController::class)
->middleware('throttle:password-reset');
RateLimiter::for('password-reset', fn (Request $request) => [
Limit::perMinute(3)->by(Str::lower((string) $request->input('email'))),
Limit::perMinute(20)->by($request->ip()),
]);
The public response should describe the same outcome even for an unknown address. Laravel's password broker returns different internal status values; the controller must deliberately map them to the same public response. After a successful reset, revoke other sessions and risk-relevant tokens according to the application's revocation strategy. Send a security notification that does not itself contain a sensitive link or plaintext credential.
MFA recovery must not silently make the second factor irrelevant. Recovery codes are secret one-time credentials. Store them hashed or with equivalent protection, revoke them individually and replace them after use.
The database password broker hashes the token, enforces expiry and issue throttling, and deletes the token after a successful reset. Validation and deletion are not automatically protected by an application-wide lock. If two exactly concurrent uses are part of the threat model, serialise the reset flow using an explicit one-time operation with a unique constraint or lock.
Recommendation: Test expired, modified and reused reset tokens. Higher-risk applications should also include a real concurrency test in which at most one parallel request succeeds.
Keep email changes pending until verified
Replacing the primary address immediately means a typo can break account recovery. In systems that derive privilege from a domain, an unverified address may even affect authorisation.
A robust flow stores the proposed address separately:
$request->validate([
'email' => ['required', 'email:rfc', 'unique:users,email'],
]);
$request->user()->forceFill([
'pending_email' => Str::lower($request->string('email')->toString()),
'pending_email_token' => hash('sha256', $token = Str::random(64)),
'pending_email_expires_at' => now()->addMinutes(30),
])->save();
Only after confirmation does pending_email become primary. A recent identity confirmation and notification to the current address are appropriate for this change. Domain-based access must never rely solely on an unverified address.
Recommendation: Represent current, pending and verified addresses separately. Test collisions, expired links, concurrent changes and cancellation by the existing account owner.
Bind signed URLs to the intended identity
A valid Laravel signature proves that a URL was not modified. It does not automatically prove which user or business state the URL was intended for.
$url = URL::temporarySignedRoute(
'magic-login.consume',
now()->addMinutes(10),
['login_attempt' => $attempt->uuid],
);
Route::get('/magic-login/{login_attempt}', ConsumeMagicLoginController::class)
->middleware(['guest', 'signed'])
->name('magic-login.consume');
The server-side record for the unguessable login_attempt UUID stores the expected identity, its own expiry and consumed_at. When the link is requested, verify the URL signature and record, then consume it atomically. The separate expiry check is deliberate so business validity does not depend only on a URL parameter:
$userId = DB::transaction(function () use ($request): int {
$attempt = LoginAttempt::query()
->lockForUpdate()
->where('uuid', $request->route('login_attempt'))
->whereNull('consumed_at')
->where('expires_at', '>', now())
->firstOrFail();
$attempt->update(['consumed_at' => now()]);
return $attempt->user_id;
});
Auth::loginUsingId($userId);
$request->session()->regenerate();
Context binding also applies to invitations, previews and downloads. Permission may change after a link was generated and must be checked again for sensitive resources.
Recommendation: Use an unguessable, business-specific reference and make privileged links single-use. A URL signature is an integrity control, not a complete authorisation model.
MFA protects more than login
A second factor at login has limited value if password, email, payout destination or MFA device can be changed afterwards without renewed confirmation. Critical actions need step-up authentication with a short validity period.
Route::post('/account/mfa/replace', ReplaceMfaController::class)
->middleware(['auth', 'password.confirm', RequireRecentMfa::class]);
TOTP secrets and recovery codes are credentials but need different storage. The TOTP secret must remain recoverable for code verification and should therefore be encrypted. Recovery codes can be hashed individually because their use requires only comparison. A new factor becomes active only after a valid code has been confirmed. Keep the old factor until the safe transition completes or replace it through a separate recovery process.
Recommendation: Test enrolment, confirmation, replay, time drift, recovery-code use, factor replacement and complete account recovery. Support staff must not bypass MFA through an informal individual decision.
Build a negative test matrix
A secure authentication suite does not test only successful login:
| Flow | Negative security case | Expected result |
|---|---|---|
| Login | valid email, wrong password, sixth attempt | limited, no enumeration |
| Session | session ID fixed before login | replaced after login |
| Logout | old cookie is used again | unauthenticated |
| Password reset | token used twice in parallel | exactly one change |
| Email change | new address not verified | current address remains primary |
| Magic link | valid link used in another context | controlled rejection |
| MFA | recovery code used twice | second attempt fails |
| Suspension | suspended account uses API or broadcast | no access |
Browser-level tests should also inspect cookie attributes, redirects, CORS and CSRF behaviour. A controller unit test cannot observe which headers a reverse proxy actually delivers.
Practical review checklist
- All guards, login paths and credential types are inventoried.
- Login and recovery avoid account enumeration and have layered rate limits.
- Session identifiers rotate during login and invalidation.
- Production cookies explicitly use
Secure,HttpOnlyand the appropriateSameSitemode. - State-changing browser requests use safe HTTP methods and CSRF protection.
- Password reset, email change and MFA provide single use, expiry and notification.
- Critical account actions require recent password or MFA confirmation.
- Suspension and revocation cover APIs, broadcasts, jobs and existing sessions.
- Signed links contain a unique identity or operation binding.
- Negative tests prove every relevant state transition.
Related Laravel security articles
- 10 common security mistakes in Laravel projects
- Separating tenant isolation and authorisation in Laravel
- Laravel security audit: a practical checklist
Sources and further reading
- Laravel 13: Authentication
- Laravel 13: Email Verification
- Laravel 13: Rate Limiting
- Laravel 13: URL Generation and Signed URLs
- OWASP: Authentication Cheat Sheet
- OWASP: Session Management Cheat Sheet
- Securing Laravel: SameSite Cookies
- Securing Laravel: The Signed URL Trap
Conclusion
Laravel provides strong primitives for authentication and session management. The decisive security work is connecting those primitives consistently across the complete account lifecycle. Explicit states, credential revocation and negative tests prevent vulnerabilities that emerge between two individually reasonable implementations.