Laravel Version: 13.31.0 (12.x is affected identically — see History)
PHP Version: 8.5.4
Database Driver & Version: MySQL 8.4.11
Description
SessionGuard::userFromRecaller() throws a TypeError — a 500 on every request — when the user matched by the remember-me cookie has a null auth password:
TypeError: hash_equals(): Argument #1 ($known_string) must be of type string, null given
at vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:239
#0 SessionGuard.php(239): hash_equals()
#1 SessionGuard.php(197): Illuminate\Auth\SessionGuard->userFromRecaller()
#2 AuthManager.php(61): Illuminate\Auth\SessionGuard->user()
#3 AuthServiceProvider.php(89): call_user_func()
#4 Request.php(677): call_user_func()
The code, identical in 12.x and 13.x:
$userPassword = $user->getAuthPassword();
$recallerHash = $recaller->hash();
return (hash_equals($this->hashPasswordForCookie($userPassword), $recallerHash)
|| hash_equals($userPassword, $recallerHash))
? $user : null;
We run a passwordless application — login is OTP and passkey only, and the users table has no password column at all — so getAuthPassword() returns null for every user. After upgrading from 13.26.1 to 13.30.1, every user holding a remember-me cookie got a 500 on their first request once the session expired: home page, login page, every route. It keeps happening on each request until the cookie expires or the user clears it, and since the crash happens while resolving the user, there is no logout path to recover from it in the browser.
Two separate things go wrong:
hashPasswordForCookie(null) calls hash_hmac('sha256', null, ...), which raises Deprecated: hash_hmac(): Passing null to parameter #2 ($data) of type string is deprecated and returns the HMAC of the empty string. So the first comparison silently compares against the wrong value.
- The second
hash_equals($userPassword, $recallerHash) then gets the raw null and throws.
Cookies issued before #61386 store the auth password itself as the third segment, which for these users is an empty string. Recaller::hasAllSegments() only requires the first two segments to be non-empty, so such a cookie passes valid() and reaches the comparison.
Worth noting that the deprecation in (1) also fires on the write path: queueRecallerCookie() calls hashPasswordForCookie($user->getAuthPassword()), so the framework itself feeds a null to hash_hmac() when issuing the cookie. Under PHP 9 that stops being a deprecation.
Why this reads as a bug rather than a misconfiguration
A null auth password is treated as a supported state everywhere else in the same component:
EloquentUserProvider::validateCredentials() — if (is_null($hashed = $user->getAuthPassword())) { return false; }
DatabaseUserProvider::validateCredentials() — identical guard
Illuminate\Session\Middleware\AuthenticateSession::handle() — if (! $request->hasSession() || ! $request->user() || ! $request->user()->getAuthPassword()) { return $next($request); }
userFromRecaller() is now the only place in the auth stack that assumes a string.
History
Both 12.x and 13.x still carry the unguarded code as of today, and the change reached 13.x through commit 3005430 ("port fixes from 12.x"), released in v13.30.1 — which is not mentioned in that release's notes, so there was nothing to react to at upgrade time.
What I am actually asking
If a null getAuthPassword() is no longer supported, that is a perfectly reasonable answer — but then it is a behavioural break that deserves a line in the upgrade guide, and the three null-guards listed above are dead code that suggests the opposite. If it is still supported, userFromRecaller() needs the guard that AuthenticateSession already has. Either way, an application should not be able to 500 itself on every route with a cookie the framework issued.
Steps To Reproduce
On a fresh application:
- Make
users.password nullable, or remove the column so getAuthPassword() returns null.
- While on 13.29.0 (or 12.68), call
Auth::login($user, remember: true). Equivalently, hand-craft the cookie value as {id}|{remember_token}| — an empty third segment is what these users get.
- Upgrade to 13.30.1 or later (12.69 or later on
12.x).
- Delete the session cookie, keep
remember_web_*, and request any route that resolves the user.
Every request returns 500 until the cookie is gone. On 13.29.0 the same cookie logs the user in.
Workaround
For anyone who lands here from a search, on the application side:
// app/Models/User.php
public function getAuthPassword(): string
{
return '';
}
Existing cookies keep working — the empty third segment matches on the second comparison, new ones match on the first — and AuthenticateSession still skips its check. The remember token itself is still verified by retrieveByToken(), so a forged cookie still resolves to a guest.
Laravel Version: 13.31.0 (
12.xis affected identically — see History)PHP Version: 8.5.4
Database Driver & Version: MySQL 8.4.11
Description
SessionGuard::userFromRecaller()throws aTypeError— a 500 on every request — when the user matched by the remember-me cookie has a null auth password:The code, identical in
12.xand13.x:We run a passwordless application — login is OTP and passkey only, and the
userstable has nopasswordcolumn at all — sogetAuthPassword()returnsnullfor every user. After upgrading from 13.26.1 to 13.30.1, every user holding a remember-me cookie got a 500 on their first request once the session expired: home page, login page, every route. It keeps happening on each request until the cookie expires or the user clears it, and since the crash happens while resolving the user, there is no logout path to recover from it in the browser.Two separate things go wrong:
hashPasswordForCookie(null)callshash_hmac('sha256', null, ...), which raisesDeprecated: hash_hmac(): Passing null to parameter #2 ($data) of type string is deprecatedand returns the HMAC of the empty string. So the first comparison silently compares against the wrong value.hash_equals($userPassword, $recallerHash)then gets the rawnulland throws.Cookies issued before #61386 store the auth password itself as the third segment, which for these users is an empty string.
Recaller::hasAllSegments()only requires the first two segments to be non-empty, so such a cookie passesvalid()and reaches the comparison.Worth noting that the deprecation in (1) also fires on the write path:
queueRecallerCookie()callshashPasswordForCookie($user->getAuthPassword()), so the framework itself feeds a null tohash_hmac()when issuing the cookie. Under PHP 9 that stops being a deprecation.Why this reads as a bug rather than a misconfiguration
A null auth password is treated as a supported state everywhere else in the same component:
EloquentUserProvider::validateCredentials()—if (is_null($hashed = $user->getAuthPassword())) { return false; }DatabaseUserProvider::validateCredentials()— identical guardIlluminate\Session\Middleware\AuthenticateSession::handle()—if (! $request->hasSession() || ! $request->user() || ! $request->user()->getAuthPassword()) { return $next($request); }userFromRecaller()is now the only place in the auth stack that assumes a string.History
userFromRecaller()in12.x.TypeErroron the no matching user path; fixed by [12.x] Fix TypeError in userFromRecaller() when the recaller matches no user #61397 withif (! $this->viaRemember) { return; }. That guard is present in 13.30.1+.Both
12.xand13.xstill carry the unguarded code as of today, and the change reached13.xthrough commit3005430("port fixes from 12.x"), released in v13.30.1 — which is not mentioned in that release's notes, so there was nothing to react to at upgrade time.What I am actually asking
If a null
getAuthPassword()is no longer supported, that is a perfectly reasonable answer — but then it is a behavioural break that deserves a line in the upgrade guide, and the three null-guards listed above are dead code that suggests the opposite. If it is still supported,userFromRecaller()needs the guard thatAuthenticateSessionalready has. Either way, an application should not be able to 500 itself on every route with a cookie the framework issued.Steps To Reproduce
On a fresh application:
users.passwordnullable, or remove the column sogetAuthPassword()returns null.Auth::login($user, remember: true). Equivalently, hand-craft the cookie value as{id}|{remember_token}|— an empty third segment is what these users get.12.x).remember_web_*, and request any route that resolves the user.Every request returns 500 until the cookie is gone. On 13.29.0 the same cookie logs the user in.
Workaround
For anyone who lands here from a search, on the application side:
Existing cookies keep working — the empty third segment matches on the second comparison, new ones match on the first — and
AuthenticateSessionstill skips its check. The remember token itself is still verified byretrieveByToken(), so a forged cookie still resolves to a guest.