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:
- Privacy browsers (e.g. Brave with Global Privacy Control) dropping
Set-Cookie on non-navigation / subresource responses.
- 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
- 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.
- 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.
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(defaulttrue) sets a second cookie{sessionName}_challengeonce, at login, and never reissues it.Session::___isValidSession()runs on every request and callslogout()when that cookie no longer matches the stored_user.challenge:Because the cookie is set only at login and never refreshed, it desyncs from the server-stored challenge whenever a login-response
Set-Cookiefails to persist. Two increasingly common triggers:Set-Cookieon non-navigation / subresource responses.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.txtshows the churn deterministically — a successful login followed ~1 minute later by an invalidation, then a re-login: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
session.use_strict_mode = 1inSession::___init(), alongside thecookie_secure/cookie_httponly/gc_*ini_set()calls it already makes beforesession_start(). This is the modern, handler-level session-fixation defense — exactly whatsessionChallengewas compensating for.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 beforesession_start()):Environment: ProcessWire dev branch, PHP 8.5, default
filessession handler.