If you ship email validation with CakeDC/Users, you eventually hit a question the plugin quietly hands back to you: what should happen when someone registers, never clicks the validation link, and then tries to log in?
The honest answer is that CakeDC/Users doesn't decide for you. Out of the box you get a database column, a couple of behaviors, and a set of events — but the experience is yours to assemble. Get it wrong and you land in one of two bad places: a user silently logged in without ever validating, or a user who typed the right password and is told "username or password is incorrect." Neither is what you want.
This post walks through why that happens in v16, and a clean way to wire the flow using the events the plugin already dispatches — no core hacks, no schema surgery.
One flag, two meanings
Everything starts with a single boolean column on the users table: active. When email validation is on, registration creates the account with active = 0 and only flips it to 1 when the user clicks the link in the validation email. You can trace it in BaseTokenBehavior::_updateActive():
// $user['validated'] is a transient flag set to false during register()
$emailValidated = $user['validated'];
if (!$emailValidated && $validateEmail) {
$user['active'] = false; // registered → inactive + token emailed
$user->updateToken($tokenExpiration);
} else {
$user['active'] = true; // clicked the link → active
$user['activation_date'] = new DateTime();
}
Notice there is no separate validated column in the database — $user['validated'] is a transient property used only during registration. The persisted truth is active, and it is doing two jobs at once:
- "Has this person confirmed their email?" — set by the validation flow.
- "Is this account enabled?" — the thing an admin toggles to ban or suspend someone.
That conflation is the root of everything below. Hold onto it; we'll come back to it.
How the finder decides who exists
Login in CakeDC/Users runs through cakephp/authentication's password identifier, which resolves the user with a finder. The finder you choose changes the entire shape of the problem:
| finder | Behaviour for an inactive account (active = 0) |
|---|---|
'active' |
The WHERE active = 1 clause means the row is never found. The password is never checked. The user gets the generic "username or password is incorrect" — even with the correct password. |
'all' |
The row is found and the password is verified. With a correct password the identifier succeeds — so an unvalidated user is technically authenticated. |
Here's the counter-intuitive part. It's tempting to reach for finder => 'active' — surely inactive users just shouldn't log in? But that choice produces the second bad experience: a real user, with the right password, whose only sin is not clicking an email that probably landed in spam, is told their credentials are wrong. They'll try again, reset their password, and file a support ticket. They have no way of knowing the real problem.
Common myth: "
finder => 'all'is there to prevent user enumeration." It isn't. The form login failure maps both "user not found" and "wrong password" to the same generic message anyway (LoginComponentLoader::forFormwires no distinct not-active message). So'active'leaks nothing — it just also blocks the legitimate not-yet-validated user with a confusing message.
The better flow: authenticate, then decide
The move that fixes both bad outcomes: keep finder => 'all' so the password is verified, then intercept the result after authentication and branch on active. That's the only point where you can tell "wrong password" apart from "correct password, not validated" — and give each a truthful message.
CakeDC/Users hands you the exact hook. LoginComponent::afterIdentifyUser() dispatches Users.Authentication.afterLogin, and if a listener returns an array, that array becomes the redirect, short-circuiting the normal login redirect:
public function afterLogin(EventInterface $event): ?array
{
$user = $event->getData('user');
if ($user === null || !empty($user['active'])) {
return null; // active → let normal login proceed
}
// Inactive account must never keep a session. The auth middleware
// already persisted the identity — drop it right back.
$this->Authentication->logout();
$this->getRequest()->getSession()->delete('Flash'); // drop the "Welcome" flash
if (Configure::read('Users.Email.validate')) {
$this->Flash->error(__(
'Your account is not validated yet. Click the link in the ' .
'email we sent you — check spam, or request a new one below.'
));
return ['action' => 'resendTokenValidation'];
}
// validation off ⇒ active=0 can only mean an admin disabled the account
$this->Flash->error(__('Your account is disabled. Please contact an administrator.'));
return ['action' => 'login'];
}
Two details make this safe. First, the auth middleware has already written the identity to the session by the time your action runs, so simply redirecting isn't enough — you must call $this->Authentication->logout() to drop it. An unvalidated account should never hold a session, not even for one request. Second, LoginComponent queues a "Welcome" success flash before this event fires, so you clear it before setting your own.
Here are the three outcomes at /login:
| Credentials | active |
Result |
|---|---|---|
| correct password | 1 |
Logged in. Normal redirect to the dashboard. |
| correct password | 0 |
Session dropped. Redirected to the resend page with a specific "validate your account" message. |
| wrong password / no such user | — | Generic error. "Username or password is incorrect." No session, no enumeration leak. |
Why this doesn't leak: the specific "not validated" message only ever appears after a correct password — i.e. to someone who has already proven the account exists. Wrong-password and unknown-user still collapse into one generic message. You get honesty for real users and opacity for attackers.
After register: tell them to check their email
The other half of the problem is that a fresh registrant has no idea a validation email is even coming. By default the plugin flashes "please validate your account before log in" and drops them on the login screen — where the resend option is nowhere in sight. So send them somewhere that explains the next step.
RegisterTrait::_afterRegister() dispatches Users.Global.afterRegister. Careful: unlike the login hook, this one only honours a return value that is a Response instance — an array is ignored. So return a redirect response, not a URL array:
public function afterRegister(EventInterface $event): ?Response
{
if (!Configure::read('Users.Email.validate')) {
return null; // nothing to validate → default "log in" redirect stands
}
$this->Flash->success(__(
'Almost done! We sent a validation link to your email. Click it ' .
'to activate your account — check spam, or request a new link below.'
));
return $this->redirect(['action' => 'resendTokenValidation']);
}
Both listeners land on the same destination: resendTokenValidation. That action already ships with the plugin (in UserValidationTrait) — it takes an email or username, regenerates the token, and re-sends the validation email, refusing if the account is already active. You just need a themed template for it. Wiring both events is a two-line job in your controller's initialize():
$this->getEventManager()->on(
UsersPlugin::EVENT_AFTER_LOGIN,
fn(EventInterface $e) => $this->afterLogin($e),
);
$this->getEventManager()->on(
UsersPlugin::EVENT_AFTER_REGISTER,
fn(EventInterface $e) => $this->afterRegister($e),
);
Mind the return contracts:
afterLoginmust return an array (afterIdentifyUserchecksis_array()).afterRegistermust return aResponse(_afterRegisterchecksinstanceof Response). Same pattern, two different types — mixing them up gives you a listener that silently does nothing.
The caveat you've only postponed
Remember the two jobs active is doing? This flow leans on the "email confirmed" meaning, but the column can't tell that apart from the "admin disabled this account" meaning. Both are active = 0. Which means:
A user an admin deliberately banned can visit the resend page, request a "validation" email, click the link, and re-activate their own account. The resendTokenValidation action only checks that the account isn't already active — it can't know why it's inactive.
The real fix: split the concepts. Add a dedicated
enabledcolumn (admin on/off), keepactivefor email confirmation, and require both in your login finder. Now "never validated" and "banned" are distinct states, and a resend can't undo a ban. Until you do that, only offer resend when email validation is on — and bounce a disabled account (validation off) straight back to login with a neutral message, as the listener above does.
For an existing app, adding a column is a migration and a finder tweak. For a greenfield build it's nearly free — so if you're starting fresh, separate them from day one and skip the trap entirely.
The short version
-
Keep
finder => 'all'. You need the password verified to distinguish "wrong password" from "not validated." It costs you no enumeration protection. -
Branch after authentication, not before. The
Users.Authentication.afterLoginevent is where the truthful decision lives. - Always drop the session for an inactive account — the middleware already created it.
- Guide the registrant. Redirect after register to a page that explains the email and offers a resend.
-
Don't trust one flag with two meanings. An
enabledcolumn is the difference between "restart validation" and "let a banned user back in."
None of this requires forking the plugin. CakeDC/Users gives you the seams — the events, the finder switch, the built-in resend action. It just leaves the last mile, the part your users actually feel, for you to build.
Built against CakeDC/Users v16 on CakePHP 5. The event names and method signatures referenced here are from that line; earlier majors dispatch similar events under different names. Spot something I got wrong? Drop a comment.
Top comments (0)