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 full
wp_authenticate_username_password() flow: user lookup, wp_check_password()
against the stored bcrypt hash, every authenticate filter, and cookie
issuance. A custom md5()/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.php or wp_signon(). Increment a transient
counter on wp_login_failed, block in the wp_authenticate_user filter once
it exceeds a threshold, and clear it on wp_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 core
get_client_ip(). X-Forwarded-For and 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() or
WP_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_errors filter can make every failure message uniform.
- Validate redirects; authentication is not authorization. A login form
that trusts
redirect_to is an open redirect (phishing pivot) — pass it
through wp_validate_redirect() and send with wp_safe_redirect(). And
is_user_logged_in() proves identity only; gate actions with
current_user_can().
Step-by-step implementation
- Sign users in with
wp_signon() and check the result:$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.
}
Call it before output is sent (e.g. on 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 through wp_signon():// 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 );
Counter: 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_errors filter. 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, call
WP_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, call
wp_destroy_other_sessions(), else destroy_all() for that user.
- Role elevation/demotion: hook
profile_update, compare roles, then
WP_Session_Tokens::get_instance( $user_id )->destroy_all().
- Current user logout-everywhere:
wp_destroy_all_sessions().
add_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;
}
- After login, redirect only to validated targets:
$target = wp_validate_redirect( $redirect_to, home_url( '/' ) );
wp_safe_redirect( $target );
exit;
- Custom login forms need a nonce (
wp_nonce_field at render,
check_admin_referer at submit) so third-party sites cannot silently sign
visitors in, and must still delegate the credential check to wp_signon().
- After login, check capabilities before privileged actions:
current_user_can( 'edit_posts' ) or user_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
// ❌ 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 );
}
// ✅ 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
// ❌ 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 ) );
}
// ✅ 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
// ❌ 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'];
// ✅ 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
// ❌ Insecure: plaintext user id in a cookie is a one-line auth bypass.
setcookie( 'myauth', (string) $user_id, time() + MONTH_IN_SECONDS, '/' );
// ✅ 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
// ❌ 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 );
}
// ✅ 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
// ❌ 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;
} );
// ✅ 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;
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
Official references
1---2name: authentication-session-security3description: Use when code logs users in or out, sets or clears auth cookies, manages session tokens, throttles failed logins, or builds a custom login form in WordPress. Enforces wp_signon() and core session primitives over hand-rolled credential checks, adds brute-force throttling via the wp_authenticate_user filter keyed on username + IP, destroys sessions after password or role changes, makes login error messages uniform to stop user enumeration, and validates redirect_to with wp_safe_redirect() to close open redirects.4license: MIT5---67# Authentication & session security89## When to use this skill1011Use this skill whenever code touches the login or session lifecycle:1213- Signing users in with `wp_signon()` or a custom login form / endpoint.14- Any `wp_ajax_nopriv_` or REST route that accepts a username and password.15- Reading or setting auth cookies, or handling "remember me".16- Creating, listing, or destroying sessions (`WP_Session_Tokens`).17- Handling `wp_login_failed`, `wp_login`, password changes, or role changes.18- Building a front-end login page that redirects via `redirect_to`.19- Gating behavior on `is_user_logged_in()`.2021WordPress ships complete, battle-tested authentication primitives: `wp_signon()`22for credential checks, signed hashed auth cookies, and per-user session stores.23Vulnerabilities come from agents rolling custom auth beside them — unthrottled24custom login endpoints, hand-rolled cookies, sessions kept alive after a25privilege change, and user enumeration through login error messages.2627Related: see the `secrets-credentials-management` skill for how secrets and28hashes are stored, the `capability-permission-checks` skill for authorization29after login, and the `input-sanitization-validation` skill for redirect input30handling.3132## Core principles (and why they matter)33341. **Never implement credential checking yourself.** `wp_signon()` runs the full35 `wp_authenticate_username_password()` flow: user lookup, `wp_check_password()`36 against the stored bcrypt hash, every `authenticate` filter, and cookie37 issuance. A custom `md5()`/`sha1()`/`==` comparison is a catastrophic bug —38 it bypasses password hashing policy and usually leaks timing or stores39 replayable secrets.402. **Throttle failed logins; core has no rate limiting.** WordPress applies zero41 rate limiting to `wp-login.php` or `wp_signon()`. Increment a transient42 counter on `wp_login_failed`, block in the `wp_authenticate_user` filter once43 it exceeds a threshold, and clear it on `wp_login`. Key on username + IP44 combined: IP-only punishes everyone behind one NAT gateway, username-only45 lets an attacker lock a victim out of their own account.463. **Read IPs from `$_SERVER['REMOTE_ADDR']` only.** There is no core47 `get_client_ip()`. `X-Forwarded-For` and similar headers are client-48 controlled; trusting them lets an attacker rotate a fresh "IP" per request49 and defeat IP-keyed throttling.504. **Destroy sessions when identity or trust changes.** Core auth cookies embed51 a fragment of the stored password hash, so a password change invalidates52 previously issued cookies — but the session tokens themselves remain in the53 store. `wp_destroy_other_sessions()` after a password change;54 `wp_destroy_all_sessions()` or55 `WP_Session_Tokens::get_instance( $user_id )->destroy_all()` when an admin56 forces a logout or a role is elevated or demoted.575. **Let core set auth cookies.** Never `setcookie()` your own login-state58 token. Core cookies are HMAC-signed, expiring, and tied to the session59 store. Tune core behavior through verified filters only:60 `auth_cookie_expiration` (duration), `secure_auth_cookie` /61 `secure_logged_in_cookie` (HTTPS-only).626. **Do not leak account existence.** Default login messages differ for unknown63 usernames vs wrong passwords, giving attackers a free account-existence64 oracle. The `login_errors` filter can make every failure message uniform.657. **Validate redirects; authentication is not authorization.** A login form66 that trusts `redirect_to` is an open redirect (phishing pivot) — pass it67 through `wp_validate_redirect()` and send with `wp_safe_redirect()`. And68 `is_user_logged_in()` proves identity only; gate actions with69 `current_user_can()`.7071## Step-by-step implementation72731. Sign users in with `wp_signon()` and check the result:74 ```php75 $user = wp_signon(76 array(77 'user_login' => $username,78 'user_password' => $password,79 'remember' => $remember,80 ),81 ''82 );83 if ( is_wp_error( $user ) ) {84 // Generic failure path; see the login_errors filter below.85 }86 ```87 Call it before output is sent (e.g. on `init`); it sets cookie headers.882. Throttle failed logins. Register once — these hooks fire for every login89 surface, including `wp-login.php`, `wp_ajax_nopriv_` handlers, and REST90 routes that go through `wp_signon()`:91 ```php92 // Increment on every failure (wrong password, unknown user, blocked attempt).93 add_action( 'wp_login_failed', 'my_plugin_record_failed_login' );9495 // Block once the counter exceeds the threshold.96 add_filter( 'wp_authenticate_user', 'my_plugin_check_throttle', 10, 2 );97 // The callback: return a WP_Error when the counter is over the limit;98 // return $user untouched otherwise (it may already be a WP_Error).99100 // Reset on success so one bad week does not lock the user out.101 add_action( 'wp_login', 'my_plugin_clear_failed_logins', 10, 2 );102 ```103 Counter: `set_transient( $key, $count, $window )` with104 `$key = 'my_plugin_throttle_' . md5( strtolower( $username ) . '|' . $ip )`.105 Sensible defaults: 5 attempts per 15 minutes.1063. Make login failures uniform with the `login_errors` filter. The filtered107 value is echoed unescaped — return only static, escaped text.1084. Destroy sessions on identity/trust changes:109 - Password reset from a reset link: hook `after_password_reset`, call110 `WP_Session_Tokens::get_instance( $user->ID )->destroy_all()`.111 - Password change via profile screen: hook `profile_update`, compare the112 stored hash with the old one; if changed and the actor is the user, call113 `wp_destroy_other_sessions()`, else `destroy_all()` for that user.114 - Role elevation/demotion: hook `profile_update`, compare roles, then115 `WP_Session_Tokens::get_instance( $user_id )->destroy_all()`.116 - Current user logout-everywhere: `wp_destroy_all_sessions()`.117 ```php118 add_filter( 'auth_cookie_expiration', 'my_plugin_cookie_expiry', 10, 3 );119 function my_plugin_cookie_expiry( $length, $user_id, $remember ) {120 if ( ! $remember && user_can( $user_id, 'manage_options' ) ) {121 return 2 * DAY_IN_SECONDS; // Short admin sessions unless "remember me".122 }123 return $length;124 }125 ```1266. After login, redirect only to validated targets:127 ```php128 $target = wp_validate_redirect( $redirect_to, home_url( '/' ) );129 wp_safe_redirect( $target );130 exit;131 ```1327. Custom login forms need a nonce (`wp_nonce_field` at render,133 `check_admin_referer` at submit) so third-party sites cannot silently sign134 visitors in, and must still delegate the credential check to `wp_signon()`.1358. After login, check capabilities before privileged actions:136 `current_user_can( 'edit_posts' )` or `user_can( $user_id, $cap )`.137138### Supporting references139140| Reference | Load when |141| --- | --- |142| [Authentication & session security — cheatsheet](references/cheatsheet.md) | Choosing the applicable WordPress API or control for authentication and session management. |143| [Authentication & session security — deployment checklist](references/checklist.md) | Before final verification of the authentication & session security controls. |144| [Secure authentication and sessions](references/secure-authentication.php) | Implementing the login, throttling, session-revocation, and safe-redirect flow. |145146## Common AI mistakes / anti-patterns147148### Mistake 1 — Rolling a custom credential check149150```php151// ❌ Insecure: md5 comparison beside core auth; bypasses bcrypt and any plugin's checks.152$user = get_user_by( 'login', $_POST['user'] );153if ( $user && md5( $_POST['pass'] ) === get_user_meta( $user->ID, 'pass_hash', true ) ) {154 my_plugin_grant_access( $user );155}156```157158```php159// ✅ Secure: wp_signon() runs the entire core flow, including wp_check_password().160add_action( 'init', 'my_plugin_handle_login' );161function my_plugin_handle_login() {162 if ( empty( $_POST['my_plugin_login'] ) ) {163 return;164 }165 check_admin_referer( 'my_plugin_login', 'my_plugin_nonce' );166 $user = wp_signon( array(167 'user_login' => sanitize_user( wp_unslash( $_POST['user'] ), true ),168 'user_password' => isset( $_POST['pass'] ) ? (string) $_POST['pass'] : '',169 'remember' => ! empty( $_POST['remember'] ),170 ), '' );171 if ( is_wp_error( $user ) ) {172 wp_safe_redirect( home_url( '/login/?my_plugin=failed' ) );173 exit;174 }175}176```177178### Mistake 2 — Unthrottled custom login endpoint179180```php181// ❌ Insecure: a nopriv AJAX login with no rate limit — unlimited online guessing.182add_action( 'wp_ajax_nopriv_my_login', 'my_plugin_ajax_login' );183function my_plugin_ajax_login() {184 $user = wp_signon( array(185 'user_login' => sanitize_user( wp_unslash( $_POST['log'] ), true ),186 'user_password' => (string) $_POST['pwd'],187 ), '' );188 wp_send_json( is_wp_error( $user ) ? array( 'ok' => false ) : array( 'ok' => true ) );189}190```191192```php193// ✅ Secure: same handler, but the site-wide throttle hooks are installed once:194add_action( 'wp_login_failed', 'my_plugin_record_failed_login' );195add_filter( 'wp_authenticate_user', 'my_plugin_check_throttle', 10, 2 );196add_action( 'wp_login', 'my_plugin_clear_failed_logins', 10, 2 );197// wp_signon() routes through wp_authenticate(), so AJAX, REST, and198// wp-login.php logins all pass the same counter and lockout.199```200201### Mistake 3 — Throttle keyed on IP only202203```php204// ❌ Insecure: 5 failures per IP; one office NAT or CGNAT locks out hundreds205// of unrelated users, and X-Forwarded-For is attacker-controlled anyway.206$key = 'throttle_' . $_SERVER['REMOTE_ADDR'];207```208209```php210// ✅ Secure: username + REMOTE_ADDR combined, IP hashed into the key.211$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';212$key = 'throttle_' . md5( strtolower( $username ) . '|' . $ip );213```214215### Mistake 4 — Hand-rolled "remember me" cookie216217```php218// ❌ Insecure: plaintext user id in a cookie is a one-line auth bypass.219setcookie( 'myauth', (string) $user_id, time() + MONTH_IN_SECONDS, '/' );220```221222```php223// ✅ Secure: let wp_signon() / wp_set_auth_cookie() issue core cookies; the224// 'remember' credential already extends their lifetime. If a custom token is225// truly unavoidable, store only its hash server-side (wp_hash_password()) and226// compare with hash_equals() — but prefer core cookies.227$user = wp_signon( array(228 'user_login' => $username,229 'user_password' => $password,230 'remember' => true,231), '' );232```233234### Mistake 5 — Treating a password rotation as complete revocation235236```php237// ❌ Incomplete: core cookies stop validating (the cookie hash embeds part of238// the new password hash), but the old session tokens remain stored, and any239// flow that re-issues a cookie from a stored token keeps working.240function my_plugin_change_password( $user_id, $new_plaintext ) {241 wp_set_password( $new_plaintext, $user_id );242}243```244245```php246// ✅ Secure: rotate the password, then evict every session for that account.247function my_plugin_change_password( $user_id, $new_plaintext ) {248 wp_set_password( $new_plaintext, $user_id );249 WP_Session_Tokens::get_instance( $user_id )->destroy_all();250}251// For self-service changes keep the current device signed in instead:252// wp_destroy_other_sessions() after wp_get_session_token() confirms a session.253```254255### Mistake 6 — Verbose login errors and unvalidated `redirect_to`256257```php258// ❌ Insecure: echoes which usernames exist and accepts any redirect target.259add_action( 'init', function () {260 $user = wp_signon( array(), '' );261 if ( is_wp_error( $user ) ) {262 echo $user->get_error_message(); // "invalid username" vs "incorrect password".263 }264 wp_redirect( $_GET['redirect_to'] ); // Attacker-chosen offsite URL.265 exit;266} );267```268269```php270// ✅ Secure: uniform error text; validated redirect target.271add_filter( 'login_errors', 'my_plugin_uniform_login_error' );272function my_plugin_uniform_login_error() {273 return esc_html__( 'Invalid username or password.', 'my-plugin' );274}275276$target = isset( $_REQUEST['redirect_to'] ) ? wp_unslash( $_REQUEST['redirect_to'] ) : '';277wp_safe_redirect( wp_validate_redirect( $target, home_url( '/' ) ) );278exit;279```280281## Correct code examples282283A complete commented module — throttle (`wp_authenticate_user` +284`wp_login_failed`/`wp_login`), uniform `login_errors`, session destruction on285password/role changes, and a custom login form using `wp_signon` +286`check_admin_referer` + `wp_safe_redirect` — is in287[`references/secure-authentication.php`](references/secure-authentication.php).288289Also: [`references/checklist.md`](references/checklist.md) (deployment checklist)290and [`references/cheatsheet.md`](references/cheatsheet.md) (goal → API table).291292## Checklist293294- [ ] Every login path calls `wp_signon()`; no custom `md5`/`sha1`/`==` password comparison exists.295- [ ] Failed logins are throttled and the counter is keyed on username + IP (not IP alone).296- [ ] The throttle counter increments on `wp_login_failed`, is enforced in `wp_authenticate_user`, and clears on `wp_login`.297- [ ] Client IP comes from `$_SERVER['REMOTE_ADDR']`; `X-Forwarded-For` is not trusted.298- [ ] `login_errors` returns one uniform message; no username echo in failure output.299- [ ] Password resets destroy all sessions for the account (`after_password_reset`).300- [ ] Password changes destroy other sessions; role changes destroy all sessions for that user.301- [ ] No `setcookie()` call implements login state; core auth cookies are used.302- [ ] Cookie lifetime is tuned only via `auth_cookie_expiration` (and HTTPS-only via `secure_auth_cookie` / `secure_logged_in_cookie` where needed).303- [ ] Every `redirect_to` / post-login target passes `wp_validate_redirect()` before `wp_safe_redirect()`.304- [ ] Custom login forms include a nonce (`wp_nonce_field` + `check_admin_referer`).305- [ ] Post-login privileged actions check `current_user_can()` / `user_can()`, not just `is_user_logged_in()`.306307## Official references308309- [`wp_signon()`](https://developer.wordpress.org/reference/functions/wp_signon/)310- [`wp_authenticate()`](https://developer.wordpress.org/reference/functions/wp_authenticate/)311- [`wp_authenticate_user` filter](https://developer.wordpress.org/reference/hooks/wp_authenticate_user/)312- [`wp_login_failed` action](https://developer.wordpress.org/reference/hooks/wp_login_failed/)313- [`wp_login` action](https://developer.wordpress.org/reference/hooks/wp_login/)314- [`login_errors` filter](https://developer.wordpress.org/reference/hooks/login_errors/)315- [`wp_set_auth_cookie()`](https://developer.wordpress.org/reference/functions/wp_set_auth_cookie/) / [`wp_clear_auth_cookie()`](https://developer.wordpress.org/reference/functions/wp_clear_auth_cookie/)316- [`wp_logout()`](https://developer.wordpress.org/reference/functions/wp_logout/)317- [`auth_cookie_expiration` filter](https://developer.wordpress.org/reference/hooks/auth_cookie_expiration/)318- [`secure_auth_cookie` filter](https://developer.wordpress.org/reference/hooks/secure_auth_cookie/)319- [`secure_logged_in_cookie` filter](https://developer.wordpress.org/reference/hooks/secure_logged_in_cookie/)320- [`wp_get_session_token()`](https://developer.wordpress.org/reference/functions/wp_get_session_token/) / [`wp_destroy_current_session()`](https://developer.wordpress.org/reference/functions/wp_destroy_current_session/) / [`wp_destroy_other_sessions()`](https://developer.wordpress.org/reference/functions/wp_destroy_other_sessions/) / [`wp_destroy_all_sessions()`](https://developer.wordpress.org/reference/functions/wp_destroy_all_sessions/)321- [`WP_Session_Tokens`](https://developer.wordpress.org/reference/classes/wp_session_tokens/)322- [`after_password_reset`](https://developer.wordpress.org/reference/hooks/after_password_reset/) / [`profile_update`](https://developer.wordpress.org/reference/hooks/profile_update/) actions323- [`wp_set_password()`](https://developer.wordpress.org/reference/functions/wp_set_password/)324- [`wp_hash_password()`](https://developer.wordpress.org/reference/functions/wp_hash_password/) / [`wp_check_password()`](https://developer.wordpress.org/reference/functions/wp_check_password/)325- [`wp_safe_redirect()`](https://developer.wordpress.org/reference/functions/wp_safe_redirect/) / [`wp_validate_redirect()`](https://developer.wordpress.org/reference/functions/wp_validate_redirect/)326- [`get_transient()`](https://developer.wordpress.org/reference/functions/get_transient/) / [`set_transient()`](https://developer.wordpress.org/reference/functions/set_transient/) / [`delete_transient()`](https://developer.wordpress.org/reference/functions/delete_transient/)327- [`current_user_can()`](https://developer.wordpress.org/reference/functions/current_user_can/) / [`user_can()`](https://developer.wordpress.org/reference/functions/user_can/)328- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)