Skip to content

TypeError in SessionGuard::userFromRecaller() when getAuthPassword() is null (passwordless apps) #61527

Description

@OnlyTheBestYT

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:

  1. 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.
  2. 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:

  1. Make users.password nullable, or remove the column so getAuthPassword() returns null.
  2. 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.
  3. Upgrade to 13.30.1 or later (12.69 or later on 12.x).
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions