Skip to content

Session hardening: default session.use_strict_mode and reconsider sessionChallenge (challenge-cookie desync causes spurious logouts) #588

Description

@adrianbj

Follow-up / companion to #587 (cookie-independent CSRF via Origin / Sec-Fetch-Site verification). Both come out of debugging the same real-world login loop; #587 covers the CSRF-on-POST side, this one covers the session-validation side.

Problem

$config->sessionChallenge (default true) sets a second cookie {sessionName}_challenge once, at login, and never reissues it. Session::___isValidSession() runs on every request and calls logout() when that cookie no longer matches the stored _user.challenge:

// wire/core/Session.php — ___isValidSession()
if(empty($_COOKIE[$cookieName]) || ($this->get('_user', 'challenge') != $_COOKIE[$cookieName])) {
    $valid = false;
    $reason = "Error: Invalid challenge value";
}

Because the cookie is set only at login and never refreshed, it desyncs from the server-stored challenge whenever a login-response Set-Cookie fails to persist. Two increasingly common triggers:

  1. Privacy browsers (e.g. Brave with Global Privacy Control) dropping Set-Cookie on non-navigation / subresource responses.
  2. Cross-site-entry login funnels — payment-provider return URLs, OAuth callbacks, emailed deep links — where the login response's cookies are written in a context the browser later won't replay.

The browser keeps the previous challenge while the server moves on, so the next request after login fails the challenge check, logout() fires, and the user is bounced back to the login form. It presents as a login loop: "entered credentials, returned straight to the sign-in page."

Evidence

session.txt shows the churn deterministically — a successful login followed ~1 minute later by an invalidation, then a re-login:

13:11:44  ...  Successful login for 'user'
13:13:03  ...  User 'user' - Error: Invalid challenge value (IP: ...)
13:25:34  ...  Successful login for 'user'
13:26:36  ...  User 'user' - Error: Invalid challenge value (IP: ...)

Reproduced on demand: a same-origin fetch('/page/') from a logged-in privacy-browser session logs the user out on the following request.

Same class of issue core already addressed

Core already softened the fingerprint default for exactly this reason — installs after 2025-10-31 default to UA-only instead of IP+UA in Session::getFingerprint(), because IP fingerprinting was logging users out (rotating IPv6, CDNs, mobile hand-offs). The challenge cookie is the next layer with the same failure mode.

Proposal

  1. Default session.use_strict_mode = 1 in Session::___init(), alongside the cookie_secure / cookie_httponly / gc_* ini_set() calls it already makes before session_start(). This is the modern, handler-level session-fixation defense — exactly what sessionChallenge was compensating for.
  2. Reconsider defaulting sessionChallenge = true. With strict mode active it is redundant and desync-prone. Ideally keep it only as a fallback when strict mode cannot be verified — i.e. when the active save handler does not implement SID validation (some custom DB/Redis handlers), where strict mode silently no-ops. That portability is presumably why challenge has been on by default.

Explicitly not proposed: making the challenge "self-healing" (reissuing the cookie on mismatch instead of logging out). That would defeat its purpose — an attacker who plants a session id but lacks the challenge would simply be issued a fresh one.

Workaround for affected sites

In config.php (runs before session_start()):

$config->sessionChallenge = false;
ini_set('session.use_strict_mode', 1);

Environment: ProcessWire dev branch, PHP 8.5, default files session handler.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions