Authentication & session security
When to use this skill
Use this skill whenever code touches the login or session lifecycle:
- Signing users in with
wp_signon()or a custom login form / endpoint. - Any
wp_ajax_nopriv_or REST route that accepts a username and password. - Reading or setting auth cookies, or handling "remember me".
- Creating, listing, or destroying sessions (
WP_Session_Tokens). - Handling
wp_login_failed,wp_login, password changes, or role changes. - Building a front-end login page that redirects via
redirect_to. - Gating behavior on
is_user_logged_in().
WordPress ships complete, battle-tested authentication primitives: wp_signon()
for credential checks, signed hashed auth cookies, and per-user session stores.
Vulnerabilities come from agents rolling custom auth beside them — unthrottled
custom login endpoints, hand-rolled cookies, sessions kept alive after a
privilege change, and user enumeration through login error messages.
Related: see the secrets-credentials-management skill for how secrets and
hashes are stored, the capability-permission-checks skill for authorization
after login, and the input-sanitization-validation skill for redirect input
handling.
Core principles (and why they matter)
- Never implement credential checking yourself.
wp_signon()runs the fullwp_authenticate_username_password()flow: user lookup,wp_check_password()against the stored bcrypt hash, everyauthenticatefilter, and cookie issuance. A custommd5()/sha1()/==comparison is a catastrophic bug — it bypasses password hashing policy and usually leaks timing or stores replayable secrets. - Throttle failed logins; core has no rate limiting. WordPress applies zero
rate limiting to
wp-login.phporwp_signon(). Increment a transient counter onwp_login_failed, block in thewp_authenticate_userfilter once it exceeds a threshold, and clear it onwp_login. Key on username + IP combined: IP-only punishes everyone behind one NAT gateway, username-only lets an attacker lock a victim out of their own account. - Read IPs from
$_SERVER['REMOTE_ADDR']only. There is no coreget_client_ip().X-Forwarded-Forand similar headers are client- controlled; trusting them lets an attacker rotate a fresh "IP" per request and defeat IP-keyed throttling. - Destroy sessions when identity or trust changes. Core auth cookies embed
a fragment of the stored password hash, so a password change invalidates
previously issued cookies — but the session tokens themselves remain in the
store.
wp_destroy_other_sessions()after a password change;wp_destroy_all_sessions()orWP_Session_Tokens::get_instance( $user_id )->destroy_all()when an admin forces a logout or a role is elevated or demoted. - Let core set auth cookies. Never
setcookie()your own login-state token. Core cookies are HMAC-signed, expiring, and tied to the session store. Tune core behavior through verified filters only:auth_cookie_expiration(duration),secure_auth_cookie/secure_logged_in_cookie(HTTPS-only). - Do not leak account existence. Default login messages differ for unknown
usernames vs wrong passwords, giving attackers a free account-existence
oracle. The
login_errorsfilter can make every failure message uniform. - Validate redirects; authentication is not authorization. A login form
that trusts
redirect_tois an open redirect (phishing pivot) — pass it throughwp_validate_redirect()and send withwp_safe_redirect(). Andis_user_logged_in()proves identity only; gate actions withcurrent_user_can().
Step-by-step implementation
- Sign users in with
wp_signon()and check the result:
Call it before output is sent (e.g. onphp$user = wp_signon( array( 'user_login' => $username, 'user_password' => $password, 'remember' => $remember, ), '' ); if ( is_wp_error( $user ) ) { // Generic failure path; see the login_errors filter below. }init); it sets cookie headers. - Throttle failed logins. Register once — these hooks fire for every login
surface, including
wp-login.php,wp_ajax_nopriv_handlers, and REST routes that go throughwp_signon():
Counter:php// Increment on every failure (wrong password, unknown user, blocked attempt). add_action( 'wp_login_failed', 'my_plugin_record_failed_login' ); // Block once the counter exceeds the threshold. add_filter( 'wp_authenticate_user', 'my_plugin_check_throttle', 10, 2 ); // The callback: return a WP_Error when the counter is over the limit; // return $user untouched otherwise (it may already be a WP_Error). // Reset on success so one bad week does not lock the user out. add_action( 'wp_login', 'my_plugin_clear_failed_logins', 10, 2 );set_transient( $key, $count, $window )with$key = 'my_plugin_throttle_' . md5( strtolower( $username ) . '|' . $ip ). Sensible defaults: 5 attempts per 15 minutes. - Make login failures uniform with the
login_errorsfilter. The filtered value is echoed unescaped — return only static, escaped text. - Destroy sessions on identity/trust changes:
- Password reset from a reset link: hook
after_password_reset, callWP_Session_Tokens::get_instance( $user->ID )->destroy_all(). - Password change via profile screen: hook
profile_update, compare the stored hash with the old one; if changed and the actor is the user, callwp_destroy_other_sessions(), elsedestroy_all()for that user. - Role elevation/demotion: hook
profile_update, compare roles, thenWP_Session_Tokens::get_instance( $user_id )->destroy_all(). - Current user logout-everywhere:
wp_destroy_all_sessions().
phpadd_filter( 'auth_cookie_expiration', 'my_plugin_cookie_expiry', 10, 3 ); function my_plugin_cookie_expiry( $length, $user_id, $remember ) { if ( ! $remember && user_can( $user_id, 'manage_options' ) ) { return 2 * DAY_IN_SECONDS; // Short admin sessions unless "remember me". } return $length; } - Password reset from a reset link: hook
- After login, redirect only to validated targets:
php
$target = wp_validate_redirect( $redirect_to, home_url( '/' ) ); wp_safe_redirect( $target ); exit; - Custom login forms need a nonce (
wp_nonce_fieldat render,check_admin_refererat submit) so third-party sites cannot silently sign visitors in, and must still delegate the credential check towp_signon(). - After login, check capabilities before privileged actions:
current_user_can( 'edit_posts' )oruser_can( $user_id, $cap ).
Supporting references
| Reference | Load when |
|---|---|
| Authentication & session security — cheatsheet | Choosing the applicable WordPress API or control for authentication and session management. |
| Authentication & session security — deployment checklist | Before final verification of the authentication & session security controls. |
| Secure authentication and sessions | Implementing the login, throttling, session-revocation, and safe-redirect flow. |
Common AI mistakes / anti-patterns
Mistake 1 — Rolling a custom credential check
php// ❌ Insecure: md5 comparison beside core auth; bypasses bcrypt and any plugin's checks. $user = get_user_by( 'login', $_POST['user'] ); if ( $user && md5( $_POST['pass'] ) === get_user_meta( $user->ID, 'pass_hash', true ) ) { my_plugin_grant_access( $user ); }
php// ✅ Secure: wp_signon() runs the entire core flow, including wp_check_password(). add_action( 'init', 'my_plugin_handle_login' ); function my_plugin_handle_login() { if ( empty( $_POST['my_plugin_login'] ) ) { return; } check_admin_referer( 'my_plugin_login', 'my_plugin_nonce' ); $user = wp_signon( array( 'user_login' => sanitize_user( wp_unslash( $_POST['user'] ), true ), 'user_password' => isset( $_POST['pass'] ) ? (string) $_POST['pass'] : '', 'remember' => ! empty( $_POST['remember'] ), ), '' ); if ( is_wp_error( $user ) ) { wp_safe_redirect( home_url( '/login/?my_plugin=failed' ) ); exit; } }
Mistake 2 — Unthrottled custom login endpoint
php// ❌ Insecure: a nopriv AJAX login with no rate limit — unlimited online guessing. add_action( 'wp_ajax_nopriv_my_login', 'my_plugin_ajax_login' ); function my_plugin_ajax_login() { $user = wp_signon( array( 'user_login' => sanitize_user( wp_unslash( $_POST['log'] ), true ), 'user_password' => (string) $_POST['pwd'], ), '' ); wp_send_json( is_wp_error( $user ) ? array( 'ok' => false ) : array( 'ok' => true ) ); }
php// ✅ Secure: same handler, but the site-wide throttle hooks are installed once: add_action( 'wp_login_failed', 'my_plugin_record_failed_login' ); add_filter( 'wp_authenticate_user', 'my_plugin_check_throttle', 10, 2 ); add_action( 'wp_login', 'my_plugin_clear_failed_logins', 10, 2 ); // wp_signon() routes through wp_authenticate(), so AJAX, REST, and // wp-login.php logins all pass the same counter and lockout.
Mistake 3 — Throttle keyed on IP only
php// ❌ Insecure: 5 failures per IP; one office NAT or CGNAT locks out hundreds // of unrelated users, and X-Forwarded-For is attacker-controlled anyway. $key = 'throttle_' . $_SERVER['REMOTE_ADDR'];
php// ✅ Secure: username + REMOTE_ADDR combined, IP hashed into the key. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : ''; $key = 'throttle_' . md5( strtolower( $username ) . '|' . $ip );
Mistake 4 — Hand-rolled "remember me" cookie
php// ❌ Insecure: plaintext user id in a cookie is a one-line auth bypass. setcookie( 'myauth', (string) $user_id, time() + MONTH_IN_SECONDS, '/' );
php// ✅ Secure: let wp_signon() / wp_set_auth_cookie() issue core cookies; the // 'remember' credential already extends their lifetime. If a custom token is // truly unavoidable, store only its hash server-side (wp_hash_password()) and // compare with hash_equals() — but prefer core cookies. $user = wp_signon( array( 'user_login' => $username, 'user_password' => $password, 'remember' => true, ), '' );
Mistake 5 — Treating a password rotation as complete revocation
php// ❌ Incomplete: core cookies stop validating (the cookie hash embeds part of // the new password hash), but the old session tokens remain stored, and any // flow that re-issues a cookie from a stored token keeps working. function my_plugin_change_password( $user_id, $new_plaintext ) { wp_set_password( $new_plaintext, $user_id ); }
php// ✅ Secure: rotate the password, then evict every session for that account. function my_plugin_change_password( $user_id, $new_plaintext ) { wp_set_password( $new_plaintext, $user_id ); WP_Session_Tokens::get_instance( $user_id )->destroy_all(); } // For self-service changes keep the current device signed in instead: // wp_destroy_other_sessions() after wp_get_session_token() confirms a session.
Mistake 6 — Verbose login errors and unvalidated redirect_to
php// ❌ Insecure: echoes which usernames exist and accepts any redirect target. add_action( 'init', function () { $user = wp_signon( array(), '' ); if ( is_wp_error( $user ) ) { echo $user->get_error_message(); // "invalid username" vs "incorrect password". } wp_redirect( $_GET['redirect_to'] ); // Attacker-chosen offsite URL. exit; } );
php// ✅ Secure: uniform error text; validated redirect target. add_filter( 'login_errors', 'my_plugin_uniform_login_error' ); function my_plugin_uniform_login_error() { return esc_html__( 'Invalid username or password.', 'my-plugin' ); } $target = isset( $_REQUEST['redirect_to'] ) ? wp_unslash( $_REQUEST['redirect_to'] ) : ''; wp_safe_redirect( wp_validate_redirect( $target, home_url( '/' ) ) ); exit;
Mistake 7 — Enforcing a second factor on only one login path
php// ❌ Insecure: the second factor is checked only when the wp-login.php form is // submitted. XML-RPC, REST with application passwords, and the password-reset // auto-login all reach an authenticated session without it. add_action( 'login_form_login', 'my_plugin_require_otp' );
php// ✅ Secure: enforce at authentication, and decide explicitly about // non-interactive credentials. add_filter( 'authenticate', 'my_plugin_require_second_factor', 50, 3 ); add_filter( 'wp_is_application_passwords_available_for_user', 'my_plugin_app_passwords_policy', 10, 2 ); add_filter( 'xmlrpc_enabled', '__return_false' ); // Only if the site does not need XML-RPC.
List every path that yields an authenticated user (login form, XML-RPC, REST application passwords, password reset, magic links, social login, "remember this device" tokens, account recovery) and confirm each one applies the same factor or is deliberately exempted. Recovery and reset flows must not skip the second factor or let an attacker disable it without re-authentication.
Correct code examples
A complete commented module — throttle (wp_authenticate_user +
wp_login_failed/wp_login), uniform login_errors, session destruction on
password/role changes, and a custom login form using wp_signon +
check_admin_referer + wp_safe_redirect — is in
references/secure-authentication.php.
Also: references/checklist.md (deployment checklist)
and references/cheatsheet.md (goal → API table).
Checklist
- Every login path calls
wp_signon(); no custommd5/sha1/==password comparison exists. - Failed logins are throttled and the counter is keyed on username + IP (not IP alone).
- The throttle counter increments on
wp_login_failed, is enforced inwp_authenticate_user, and clears onwp_login. - Client IP comes from
$_SERVER['REMOTE_ADDR'];X-Forwarded-Foris not trusted. -
login_errorsreturns one uniform message; no username echo in failure output. - Password resets destroy all sessions for the account (
after_password_reset). - Password changes destroy other sessions; role changes destroy all sessions for that user.
- No
setcookie()call implements login state; core auth cookies are used. - Cookie lifetime is tuned only via
auth_cookie_expiration(and HTTPS-only viasecure_auth_cookie/secure_logged_in_cookiewhere needed). - Every
redirect_to/ post-login target passeswp_validate_redirect()beforewp_safe_redirect(). - Custom login forms include a nonce (
wp_nonce_field+check_admin_referer). - Post-login privileged actions check
current_user_can()/user_can(), not justis_user_logged_in(). - Every authentication path (form, XML-RPC, application passwords, reset, magic link, social login, remembered device, recovery) enforces the same second factor or is deliberately exempted.
- Disabling or resetting a second factor requires re-authentication and notifies the account owner.
- Application passwords are revoked with other sessions on compromise or role change, and custom API tokens are scoped, hashed at rest, and expire.
- Pages that vary by login state send
nocache_headers()or cache-varying headers so a page cache never serves one user's content to another. - OAuth/OIDC flows validate
state(and PKCE where supported) and an exact redirect URI allowlist.
Official references
wp_signon()wp_authenticate()wp_authenticate_userfilterwp_login_failedactionwp_loginactionlogin_errorsfilterwp_set_auth_cookie()/wp_clear_auth_cookie()wp_logout()auth_cookie_expirationfiltersecure_auth_cookiefiltersecure_logged_in_cookiefilterwp_get_session_token()/wp_destroy_current_session()/wp_destroy_other_sessions()/wp_destroy_all_sessions()WP_Session_Tokensafter_password_reset/profile_updateactionswp_set_password()wp_hash_password()/wp_check_password()wp_safe_redirect()/wp_validate_redirect()get_transient()/set_transient()/delete_transient()current_user_can()/user_can()- OWASP Authentication Cheat Sheet


