The Problem
A developer adds a custom check to the authenticate filter — say, blocking login for users with an unverified email. The simplest approach: return false if the check fails. The code ships, and suddenly nobody can log in — including the admin. No error message, the login form just reloads over and over.
The reason: authenticate expects a strictly defined response type — WP_User (success), WP_Error (an explicit rejection with a reason), or the unchanged $user (pass the decision down the chain). false is none of the three, and WordPress core has no idea what to do with it.
// Bad: returns false instead of WP_Error — WordPress core doesn't recognize
// this type, login breaks for everyone with no clear message.
add_filter( 'authenticate', function( $user, $username, $password ) {
if ( ! forwp_email_is_verified( $username ) ) {
return false;
}
return $user;
}, 30, 3 );
Why This Is Dangerous
Unlike other hook categories, a mistake here doesn’t just “break the layout” — it breaks the login itself. And it’s equally bad in both directions: false or any wrong type can lock out EVERYONE, including the admin who would need to fix the code through that same admin login that no longer works. The opposite mistake — not checking whether a previous handler already returned WP_Error, and silently returning $user as-is — opens a hole: your check is simply ignored, and anyone gets through.
Step-by-Step Solution — The Contract of Each Hook
`authenticate` (filter) — a strict return contract
add_filter( 'authenticate', function( $user, $username, $password ) {
// If a previous handler (or WordPress core itself) already returned an error —
// don't overwrite it with your own logic, or you lose the original rejection reason.
if ( is_wp_error( $user ) ) {
return $user;
}
if ( $user instanceof WP_User && ! forwp_email_is_verified( $user->ID ) ) {
return new WP_Error( 'email_not_verified', __( 'Please verify your email before logging in.' ) );
}
return $user;
}, 30, 3 );
Three rules apply here at once: return only WP_User, WP_Error, or the unchanged $user; always check is_wp_error( $user ) on input before adding your own logic; and set a priority above 20 (here, 30), because WordPress core’s standard password check runs at priority 20 — your code should see the finished result, not step in the middle of it.
`wp_login` (action) — things AFTER a successful login
add_action( 'wp_login', function( $user_login, $user ) {
forwp_log_login_event( $user->ID );
}, 10, 2 );
This isn’t a gate anymore, it’s a side effect: logging, notifications, a post-login redirect. Don’t move checks here that should have blocked the login — by this point, the login has already happened.
`wp_logout` (action) — the mirror of `wp_login`
add_action( 'wp_logout', function( $user_id ) {
forwp_clear_user_cache( $user_id );
} );
Clearing session data and any user-tied cache right after logout.
`user_register` (action) — right after registration
add_action( 'user_register', function( $user_id ) {
update_user_meta( $user_id, 'forwp_onboarding_step', 1 );
} );
The right place for new-account setup. Don’t confuse it with save_post/wp_insert_post from the Save & CRUD category — those are about posts, this one is about the user itself.
`login_url` (filter) — a classic redirect trap
add_filter( 'login_url', function( $login_url, $redirect, $force_reauth ) {
if ( $redirect ) {
$login_url = add_query_arg( 'redirect_to', urlencode( $redirect ), $login_url );
}
return $login_url;
}, 10, 3 );
If you customize the login URL and forget to carry redirect_to over to the new URL, the user lands somewhere other than where they were headed after login — and sometimes ends up in an infinite redirect loop between the custom login page and wp-login.php.
When It’s Better to Bring In a Specialist
A mistake in these hooks isn’t a bug you can “check later” — it either locks the entire site out of login immediately, or opens a door nobody finds out about until an incident happens. Auditing the login flow is exactly the scenario where a self-fix by trial and error is riskier than a specialist’s consultation: a WordPress developer with relevant security experience checks the priority chain on authenticate and the return types precisely, in minutes, instead of risking locking down production a second time through guesswork.
FAQ
Because false (or any unexpected type) confuses the entire authenticate filter chain — WordPress core can’t determine whether to continue the password check or not, and as a result the login form simply doesn’t process any request correctly, regardless of who’s trying to log in.
Several functions can be hooked to authenticate at once — your own, plugins’, and WordPress core itself (the username/password check also runs through this filter, at priority 20). Each subsequent one receives the previous result as the incoming $user — which is why you must always check whether it’s already a WP_Error before changing anything.
Technically yes, but it’s a cruder solution: wp_die() halts execution immediately, without the standard error message in the login form, and makes testing harder. WP_Error is the expected contract that WordPress core knows how to display to the user properly.
user_register is an action hook that fires right after a new user successfully registers, convenient for side effects (meta data, welcome emails). wp_insert_user is a broader action that fires on any user insert, including accounts created programmatically by an admin, not only through the registration form.
The main rule — always check is_wp_error( $user ) on input and don’t change the result if it’s already set by someone else. The second step — test login immediately after deploying, in a staging environment, not just on the developer’s local machine.
If the site already has several plugins with their own authentication logic (2FA, SSO, custom roles), or if “strange” login lockouts have happened before with no clear cause — that’s a signal to review the entire authenticate chain holistically, rather than patching symptoms one at a time.
Summary
A full breakdown of each of the 5 hooks in this category — with examples and a hands-on IDE — is on the Auth Hooks page, and the whole set is also available as one PDF to keep.


