My users kept complaining that the admin panel logged them out "all the time", even with Remember me checked. I assumed it was just the PHP session expiring. Half of that was right. The other half was a quiet configuration trap in the CakePHP authentication stack. It probably affects other CakePHP 5 apps that use cakedc/users with the default authenticators.
TL;DR: the Cookie authenticator in CakeDC/Users' default config has no identifier of its own. In cakephp/authentication 3.3, an authenticator without one inherits the identifier of the previous authenticator that had one. In the default order, that's the Token authenticator, which looks users up by api_token. So the remember-me cookie is written but never matches anyone. Give Cookie its own identifier and it works.
Versions where I hit it: CakePHP 5.4, cakedc/users 16.0.4, cakedc/auth (its CookieAuthenticator), cakephp/authentication 3.3.7.
The symptoms
- You sign in with Remember me checked.
- The browser does get a cookie. It's called
CookieAuth;remember_meis only the name of the form checkbox. It'shttponlyand expires in one month. - Once the PHP session ends (garbage collection, server restart, deploy, or you delete the session cookie), the next request redirects to
/login. The remember-me cookie is still there, but it is ignored. - Nothing is logged. To the framework, a cookie that matches no user is just a failed authentication, so it moves on to the next authenticator.
To reproduce it quickly, sign in with Remember me, delete only the session cookie (CAKEPHP by default) in devtools, and reload. If you land on the login page, you have this problem.
How authenticators get their identifier
cakephp/authentication separates authenticators (where the credentials come from: session, form, token, cookie…) from identifiers (how those credentials become a user: password check, token lookup…).
Since 3.3, the recommended style is to put the identifier inside each authenticator's config. loadIdentifier() is deprecated. Here is how the collection builds each authenticator (src/Authenticator/AuthenticatorCollection.php):
protected function _create(object|string $class, string $alias, array $config): AuthenticatorInterface { if (is_string($class)) { if (!empty($config['identifier'])) { $this->_identifiers = new IdentifierCollection((array)$config['identifier']); } return new $class($this->_identifiers, $config); } return $class; } $this->_identifiers is a property of the collection, shared by every authenticator it creates. An authenticator with an identifier replaces it. An authenticator without one gets whatever the previous one left there.
The default CakeDC/Users config
Here is the relevant part of vendor/cakedc/users/config/users.php, trimmed and in the order it appears:
'Authenticators' => [ 'Session' => [...], // no identifier (doesn't need one) 'Form' => [ 'className' => 'CakeDC/Auth.Form', 'identifier' => ['Authentication.Password' => [...]], // ① sets Password ], 'Token' => [ 'className' => 'Authentication.Token', 'identifier' => ['Authentication.Token' => [ 'tokenField' => 'api_token', // ② replaces it with Token ]], ], 'Cookie' => [ 'className' => 'CakeDC/Auth.Cookie', 'rememberMeField' => 'remember_me', 'cookie' => ['expires' => new \DateTime('+1 month'), 'httponly' => true], // ③ no identifier → inherits the Token identifier from ② ], 'Social' => [...], // ... ], The remember-me token is [username, expires, hmac]. When the cookie authenticator sees one, it asks its identifier for the user with that username and then checks the HMAC against the user's password hash. Its identifier, though, is the TokenIdentifier, which searches the api_token column. No user has their username as an API token, so the result is FAILURE_IDENTITY_NOT_FOUND and you're logged out.
Nothing in the remember-me code itself is broken. The cookie authenticator just ends up with the wrong identifier, because of where it sits in the array.
The fix
Give the Cookie authenticator an identifier of its own, in your app's users config. That's the file you pass in Users.config, loaded after the plugin's, for example config/users.php or config/app_users.php:
return [ 'Auth' => [ 'Authenticators' => [ // "Remember me": the Cookie authenticator needs its OWN identifier. // Without one, cakephp/authentication 3.3 hands it the identifier // of the previous authenticator that had one (Token / api_token), // and the cookie never finds the user. 'Cookie' => [ 'identifier' => [ 'Authentication.Password' => [ 'fields' => [ 'username' => 'username', 'password' => 'password', ], 'resolver' => [ 'className' => 'Authentication.Orm', 'finder' => 'active', ], ], ], ], ], // ... the rest of your Auth config ], ]; Some notes on this block:
- It merges with the plugin's config. You only add the
identifierkey;className, cookie options andrememberMeFieldstay as the plugin defines them. Check withdebug(Configure::read('Auth.Authenticators.Cookie'))if your config loading is unusual. - The
usernamefield must be what the cookie stores. The token is built from the cookie authenticator'sfields.username, which defaults tousername. If your users sign in by e-mail and theusernamecolumn holds the e-mail, this works as is. If you changed the cookie authenticator'sfields, use the same column here. - Keep the
activefinder, so a disabled user can't come back in through an old cookie. - You don't need to clear existing cookies. Tokens that were already issued start working, as long as they haven't expired.
A regression test
This bug fails silently, so it's worth a test that would have caught it. With IntegrationTestTrait:
public function testCookieSignsBackInAfterTheSessionEnds(): void { $this->enableCsrfToken(); $this->enableSecurityToken(); $this->post('/login', [ 'username' => 'staff@example.com', 'password' => 'secret', 'remember_me' => '1', ]); $this->assertRedirectContains('/dashboard'); // Pull the remember-me cookie out of the response $cookie = null; foreach ($this->_response->getHeader('Set-Cookie') as $header) { if (str_starts_with($header, 'CookieAuth=')) { $cookie = urldecode(explode(';', substr($header, strlen('CookieAuth=')))[0]); } } $this->assertNotEmpty($cookie); // "The session died": drop it, keep only the cookie $this->_session = []; $_SESSION = []; $this->cookie('CookieAuth', $cookie); $this->get('/dashboard'); $this->assertResponseOk(); } Add a second test with remember_me => '0' that expects a redirect to /login after the session is dropped, so you know the cookie is the only thing letting the user back in.
Two notes on the test:
-
$this->_response->getCookie('remember_me')returnsnull, because the cookie isn't calledremember_me. Read theSet-Cookieheaders, or use the configuredcookie.name. - The test failed before the fix and passed after it. Check that it fails without the fix (
git stash), or it isn't proving anything.
The same trap elsewhere
The underlying rule is general: in cakephp/authentication 3.3, an authenticator without identifier uses the identifier of the last authenticator above it that had one. So:
- If you add or enable an authenticator that identifies users (a custom one, LDAP, JWT…), give it an explicit
identifier, even if it "obviously" uses the same one as Form. - If you reorder
Auth.Authenticators, or removeTokenbecause you don't use API keys, which identifier each one inherits can change without anything telling you. - When an authentication "just fails", look at the
Resultstatus.FAILURE_IDENTITY_NOT_FOUNDwith valid credentials points at the wrong identifier, not at a bad password. You can inspect it with$this->Authentication->getResult()in a controller (->getStatus(),->getErrors()).
The other half: the PHP session itself
Once remember-me works, a short session is only a small annoyance. It's still worth fixing, because it was the reason people noticed the bug in the first place:
- PHP's default
session.gc_maxlifetimeis 1440 seconds (24 minutes). - On shared hosting, sessions often live in a shared directory where another site's lower
gc_maxlifetime, or a system cron, can delete yours early.
In config/app.php:
'Session' => [ 'defaults' => 'cake', // files in TMP/sessions, not the server-wide dir 'timeout' => 120, // minutes of inactivity (sets gc_maxlifetime) ], 'defaults' => 'cake' keeps the session files inside your app, out of the shared directory. Two things to keep in mind:
-
tmp/usually isn't in git, so createtmp/sessionson deploy, or with a guardedmkdirinconfig/bootstrap.php. It has to be writable by the web server user. - With Deployer or similar, make sure
tmpis a shared dir, or every deploy logs everyone out. - Moving the session directory invalidates the current sessions once. With remember-me fixed, users who checked the box won't even notice.
Checklist for your own CakePHP apps
-
composer show cakedc/users cakephp/authentication. Are you oncakephp/authentication3.3+ with CakeDC's defaultAuthenticators? -
debug(Configure::read('Auth.Authenticators')). DoesCookiehave anidentifier? Which authenticator with an identifier comes before it? - Manual test: sign in with Remember me, delete only the session cookie, reload.
- Add the
Cookie.identifierblock above. Use the sameusernamecolumn the cookie uses and keep theactivefinder. - Add the regression test, and make sure it fails without the fix.
- Optionally, set
Session.defaults = 'cake'and atimeout, and maketmp/sessionswritable and shared between deploys.
If you know whether this also happens in older cakephp/authentication versions, or whether CakeDC plans to add an identifier to the default Cookie config, let me know in the comments.