Authentication and Login API
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 8.4 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.1 requires PHP 7.4 at
minimum, and PHP 8.3 or newer is recommended. WordPress 7.2 is in beta
and due in December 2026; nothing on this page is expected to change with it. Source excerpts are from the 7.1.3
wp-includes/pluggable.php, and every command and request below was run against a WordPress 7.1.3 site (official
wordpress:7.1.3-php8.4-apache image, MariaDB 11.8, table prefix lp_, WP_ENVIRONMENT_TYPE set to local).
Output blocks are real, with secrets replaced by placeholders. The standard test stack is described in
Local Development Environments.
This is the developer’s view of logging in: which functions establish who the current user is, which hooks run on the way, and how passwords, cookies and sessions are stored. The administrator’s side (HTTPS, two-factor authentication, brute-force limits, pinning the bcrypt cost) is in Security Hardening; the user’s side (profiles, password resets, the Application Passwords screen) is in Users, Roles and Profiles; what an identified user may do is in Roles and Capabilities API.
The examples belong to Lumen Press, the fictitious publisher used throughout this sub-section; sam.reader is a
Subscriber.
Two steps: identifying the user and checking a password
-
Identifying the current user, on every request. Nothing happens until code first asks (
wp_get_current_user(),current_user_can(),is_user_logged_in()). Then thedetermine_current_userfilter runs. Core registerswp_validate_auth_cookie()(priority 10, the admin cookie),wp_validate_logged_in_cookie()(20, the front-end cookie, skipped insidewp-admin) andwp_validate_application_password()(20, HTTP Basic credentials). The result is stored bywp_set_current_user(), which firesset_current_user. -
Checking credentials, once, at login.
wp_signon()callswp_authenticate(), which runs theauthenticatefilter chain; on success it issues cookies withwp_set_auth_cookie()and fireswp_login.
Most of these functions live in wp-includes/pluggable.php and are pluggable: a plugin loaded earlier can replace
them (wp_hash_password(), wp_check_password(), wp_set_auth_cookie(), wp_validate_auth_cookie(),
wp_authenticate(), wp_logout()). Each now has filters, so replacing one is rarely needed; see
Hooks: Actions and Filters. On the REST API, a
request carrying only the login cookie without a valid wp_rest nonce is treated as anonymous; see
REST API: Using It.
Auth cookies
A login sets three HttpOnly cookies. Their names end in COOKIEHASH, the MD5 of the siteurl option, so two sites
on one host do not clash and changing the site URL logs everybody out:
| Constant (default name) | Path | Purpose |
|---|---|---|
|
|
Admin authentication over HTTP, scheme |
|
|
The same over HTTPS ( |
|
|
Identifies the user on the front end, scheme |
|
|
Lets |
The path restriction is a defense: the cookie that grants admin access is never sent with front-end requests. Names and
paths can be redefined in wp-config.php Reference, which only renames
them. A curl login as tom.author on the test site received exactly these: the test cookie, the admin cookie twice
(once per path) and the logged_in cookie, all HttpOnly, and a redirect to /wp-admin/.
The cookie value
A value has four fields separated by | (%7C once URL-encoded): user login, expiration timestamp, a 43-character
session token, and an HMAC-SHA256. wp_generate_auth_cookie() builds it, wp_parse_auth_cookie() splits it and
wp_validate_auth_cookie() checks it. Simplified from the 7.1.3 source, the key is
wp_hash( $login . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ) and the last field is
hash_hmac( 'sha256', $login . '|' . $expiration . '|' . $token, $key ), where $pass_frag is four characters of
the stored password hash (characters 8 to 11 for phpass and plain bcrypt hashes, the last four for the $wp$ hashes of
6.8 and later). Three consequences follow:
-
wp_hash()is an HMAC keyed withwp_salt( $scheme ):AUTH_KEYandAUTH_SALTforauth, theSECURE_AUTH_*pair forsecure_auth, theLOGGED_IN_*pair forlogged_in. Rotating the salts (wp config shuffle-salts) invalidates every cookie. -
Part of the password hash enters the key, so a password change invalidates the user’s cookies.
-
The token must also exist in the user’s session store (Sessions), so one session can be revoked alone.
A WP-CLI script (wp eval-file) shows the parts and the scheme check:
<?php
$user = get_user_by( 'login', 'tom.author' );
$cookie = wp_generate_auth_cookie( $user->ID, time() + 2 * DAY_IN_SECONDS, 'logged_in' );
WP_CLI::line( LOGGED_IN_COOKIE . ' = md5( ' . get_option( 'siteurl' ) . ' ): ' . COOKIEHASH );
foreach ( wp_parse_auth_cookie( $cookie, 'logged_in' ) as $key => $value ) {
WP_CLI::line( sprintf( '%-11s %s', $key, $value ) );
}
WP_CLI::line( 'Valid as logged_in: ' . var_export( wp_validate_auth_cookie( $cookie, 'logged_in' ), true ) );
WP_CLI::line( 'Valid as auth: ' . var_export( wp_validate_auth_cookie( $cookie, 'auth' ), true ) );
wp_set_password( '<new-password>', $user->ID ); // Changes the hash fragment in the HMAC key.
WP_CLI::line( 'After a password change: ' . var_export( wp_validate_auth_cookie( $cookie, 'logged_in' ), true ) );
wordpress_logged_in_692cae3d5f4e20ddb2e4517f93172921 = md5( http://lumenpress.test ): 692cae3d5f4e20ddb2e4517f93172921
username tom.author
expiration 1793387133
token QHvCCrha7U7KMdW139g7Q3Gj4nqkrLpp6pbNo8CM384
hmac bf3ef2b26692fbeec7b623906a90e09fd523ef3398390133d8c800d40ad5e1fa
scheme logged_in
Valid as logged_in: 3
Valid as auth: false
After a password change: false
wp_validate_auth_cookie() fires auth_cookie_malformed, auth_cookie_expired, auth_cookie_bad_username,
auth_cookie_bad_hash, auth_cookie_bad_session_token or auth_cookie_valid, useful audit points. POST and AJAX
requests get one hour of grace after expiry, so a form submitted just too late is not lost.
Lifetime. Without Remember Me the cookies last two days and are browser-session cookies; with it, 14 days. Both
pass through auth_cookie_expiration (length, user ID, remember). secure_auth_cookie and secure_logged_in_cookie
decide the Secure flag; set_auth_cookie, set_logged_in_cookie and clear_auth_cookie fire when cookies are
written or removed.
Logging in from code
wp_signon()
wp_signon( $credentials, $secure_cookie ) is the whole login: it authenticates, sets the cookies, clears a pending
password-reset key and fires wp_login, returning a WP_User or a WP_Error. The array takes user_login,
user_password and remember; with no arguments it reads log, pwd and rememberme from $_POST, as
wp-login.php does. It sends cookies, so it must run before any output, in a handler such as
admin_post_nopriv_{$action} or template_redirect, never in a shortcode or template.
The no-argument form is also the safe way to pass the password. WordPress has always hashed passwords as received in
the slashed $_POST, so passing wp_unslash( $_POST['pwd'] ) makes passwords containing a quote or a backslash
fail.
Lumen Press lets readers sign in from a Sign in page (/sign-in/) whose form (fields log, pwd, rememberme,
a hidden action of lumen_login and wp_nonce_field( 'lumen_login' )) posts to admin-post.php. The handler is a
must-use plugin, wp-content/mu-plugins/lumen-front-login.php:
<?php
/**
* Plugin Name: Lumen Front-end Login
* Description: Signs readers in from the site's own sign-in form through admin-post.php.
* Version: 0.1.0
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
add_action(
'admin_post_nopriv_lumen_login',
static function () {
check_admin_referer( 'lumen_login' );
// With no arguments, wp_signon() reads log, pwd and rememberme from $_POST itself.
$user = wp_signon();
if ( is_wp_error( $user ) ) {
wp_safe_redirect( add_query_arg( 'login', 'failed', home_url( '/sign-in/' ) ) );
exit;
}
wp_safe_redirect( home_url( '/books/' ) );
exit;
}
);
Source: Code Reference: wp_signon()
The nonce guards against login CSRF (a hostile page signing the visitor into an attacker’s account), and the failure
redirect does not say which usernames exist. On the test site a valid submission returned 302 to /books/ with the
three auth cookies, a wrong password 302 to /sign-in/?login=failed, and a submission without the nonce 403. For a
plain form, wp_login_form() prints the core form (posting to wp-login.php) with arguments such as redirect and
remember and the login_form_defaults, login_form_top, login_form_middle and login_form_bottom filters; the
Login/out block (core/loginout) does the same in the Site Editor.
wp_set_current_user() and wp_set_auth_cookie()
wp_set_current_user( $id ) only changes who the current request runs as. It sets no cookie, creates no session and
fires no wp_login: the tool for cron jobs, importers and tests that act as a user (WP-CLI’s --user does the same),
not for logging someone in. On the test site, wp_set_current_user() with the ID of tom.author turned
current_user_can( 'publish_posts' ) from false to true while wp_get_all_sessions() stayed empty.
To log a user in without a password (after an SSO callback or a verified magic link), code calls
wp_set_current_user(), then wp_set_auth_cookie( $user_id, $remember ), then
do_action( 'wp_login', $user->user_login, $user ) so audit and security plugins see the login. Such a path bypasses
authentication completely if its own check is weak, so it needs expiring, single-use tokens and review.
Logging out. wp_logout() destroys the current session token, clears the cookies, sets the current user to 0 and fires
wp_logout with the user ID (since 5.5). wp_logout_url( $redirect ) adds the log-out nonce that
wp-login.php?action=logout checks; logout_redirect chooses the destination.
The authenticate filter
wp_authenticate( $username, $password ) sanitizes the username, trims the password and returns what the
authenticate chain produces; an empty result becomes a generic authentication_failed error, and every error except
an empty field fires wp_login_failed (username and WP_Error, the latter since 5.4). The default chain in 7.1.3 is
wp_authenticate_username_password(), wp_authenticate_email_password() and wp_authenticate_application_password()
(API requests only) at priority 20; wp_authenticate_cookie() at 30, added by wp_signon() only, which accepts an
existing valid cookie when no credentials were posted; and wp_authenticate_spam_check() at 99, which refuses users
marked as spam on Multisite.
Each callback receives the result so far (null, WP_User or WP_Error), the username and the password. A callback
below 20 can replace password checking (an LDAP check returns a WP_User, which the core callbacks pass through); one
above 20 sees the resolved user and can veto it. wp_authenticate_user is a narrower filter that runs only in the
username and email paths, before the password is checked.
Suspending a reader
A user meta flag lp_suspended must block new logins, end existing sessions and stop the reader’s application
passwords. The must-use plugin wp-content/mu-plugins/lumen-login.php also records each login
(wp_login):
<?php
/**
* Plugin Name: Lumen Login
* Description: Suspended readers and last-login tracking for Lumen Press.
* Version: 0.1.0
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
/** Suspends a user: flags the account and ends every session it has. */
function lumen_suspend_user( int $user_id ): void {
update_user_meta( $user_id, 'lp_suspended', 1 );
WP_Session_Tokens::get_instance( $user_id )->destroy_all();
}
function lumen_is_suspended( WP_User $user ): bool {
return (bool) get_user_meta( $user->ID, 'lp_suspended', true );
}
// Password logins: runs after the core handlers (priority 20) have resolved the user.
add_filter(
'authenticate',
static function ( $user ) {
if ( $user instanceof WP_User && lumen_is_suspended( $user ) ) {
return new WP_Error(
'lp_suspended',
__( '<strong>Error:</strong> This account is suspended. Contact the Lumen Press team.', 'lumen' )
);
}
return $user;
},
30
);
// REST API requests with application passwords bypass the authenticate filter (XML-RPC logins do run it).
add_action(
'wp_authenticate_application_password_errors',
static function ( WP_Error $error, WP_User $user ) {
if ( lumen_is_suspended( $user ) ) {
$error->add( 'lp_suspended', __( 'This account is suspended.', 'lumen' ) );
}
},
10,
2
);
// Record the time of each successful login.
add_action(
'wp_login',
static function ( string $user_login, WP_User $user ) {
update_user_meta( $user->ID, 'lp_last_login', time() );
},
10,
2
);
Source: Code Reference: authenticate
The second hook matters: on API requests wp_validate_application_password() calls
wp_authenticate_application_password() directly, so a check placed only on authenticate leaves application
passwords working. Suspending the reader and retrying the reader’s application password:
wp user session list sam.reader --format=count
wp eval 'lumen_suspend_user( get_user_by( "login", "sam.reader" )->ID );'
wp user session list sam.reader --format=count
curl -s -u "sam.reader:$SAM_APP_PASSWORD" 'http://lumenpress.test/wp-json/wp/v2/users/me?_fields=id,name'
2
0
{"code":"rest_not_logged_in","message":"You are not currently logged in.","data":{"status":401}}
Before the suspension the request returned the reader’s ID and name; a password login on wp-login.php now shows the
suspension message. The REST client gets the generic rest_not_logged_in, not lp_suspended: on
the test site the current user was resolved lazily, after the REST server had checked authentication, so the failed
application password just left the request anonymous. Clients must not rely on the specific code; what holds is that
the request does not run as the suspended user.
Login and logout hooks
-
wp_authenticate(action): start ofwp_signon(), with username and password by reference. -
wp_login,wp_login_failed,wp_logout(actions): after a successful login (login name,WP_User), a failed one (username,WP_Error) or a logout (user ID). -
login_redirect,logout_redirect,registration_redirect,lostpassword_redirect(filters): where to go next. -
login_initandlogin_form_{$action}(actions): top ofwp-login.php, and once per action (login,logout,lostpassword,retrievepassword,rp,resetpass,register,checkemail,postpass,confirmaction,confirm_admin_email, or a custom one). -
login_enqueue_scripts,login_head,login_footer(actions): assets for the login screen, wherewp_enqueue_scriptsdoes not run. -
login_headerurl,login_headertext,login_title,login_body_class(filters): logo link and text, title, classes. -
login_message,login_errors,wp_login_errors,shake_error_codes(filters): messages, error output, and which error codes shake the form. -
login_form,register_form,lostpassword_form,resetpass_form(actions): extra fields, such as a second-factor code. A field added there must be validated in anauthenticatecallback; printing it protects nothing. -
login_display_language_dropdown(filter, 5.9): hides the language switcher.
Source: Code Reference: wp_login
On the test site, with lumen-login.php active, a login as sam.reader stored lp_last_login. A login_redirect
callback returning home_url( '/books/' ) for users without edit_posts when no redirect_to was requested sent
sam.reader to the catalogue instead of the profile, and
add_filter( 'login_headerurl', static fn() => home_url( '/' ) ) pointed the logo at the home page.
Password hashing
Since 6.8, wp_hash_password() uses bcrypt through PHP’s password_hash(). Because bcrypt ignores everything after 72
bytes, WordPress first takes an HMAC-SHA384 of the password (key wp-sha384), base64-encodes it, hashes that and
prefixes $wp: stored hashes start with $wp$2y$ and the cost ($wp$2y$12$ under PHP 8.4, whose default cost is 12).
Passwords longer than 4096 characters are refused. wp_check_password( $password, $hash, $user_id ) accepts every
format WordPress has stored:
| Stored value | Origin | Checked by |
|---|---|---|
|
6.8 and later |
|
|
phpass, 2.5 to 6.7 |
The bundled |
32 hexadecimal characters |
MD5, before 2.5, or written by hand into the database |
|
|
Plain |
|
|
Application passwords and other random keys, 6.8 and later |
|
wp_password_needs_rehash() (6.8) reports whether a hash is weaker than the current algorithm and options;
wp_authenticate_username_password() then calls wp_set_password(), so old hashes are upgraded one login at a time:
<?php
require_once ABSPATH . WPINC . '/class-phpass.php';
$password = 'correct horse battery staple';
$hashes = array(
'bcrypt (6.8+)' => wp_hash_password( $password ),
'phpass' => ( new PasswordHash( 8, true ) )->HashPassword( $password ),
'MD5' => md5( $password ),
);
foreach ( $hashes as $label => $hash ) {
WP_CLI::line( sprintf( '%-14s %-12s check=%-5s rehash=%s', $label, substr( $hash, 0, 10 ),
var_export( wp_check_password( $password, $hash ), true ),
var_export( wp_password_needs_rehash( $hash ), true ) ) );
}
WP_CLI::line( 'Wrong password: ' . var_export( wp_check_password( 'Correct horse battery staple', $hashes['bcrypt (6.8+)'] ), true ) );
bcrypt (6.8+) $wp$2y$12$ check=true rehash=false
phpass $P$BZP0R.9 check=true rehash=true
MD5 9cc2ae8a1b check=true rehash=true
Wrong password: false
The classic lock-out remedy, writing MD5('<temporary-password>') into user_pass, was tried on maria.editor: the
login succeeded and the column then started with $wp$2y$12$. It works, but leaves a fast, unsalted hash until the
next login; wp user update maria.editor --user_pass=<new-password> stores bcrypt directly. Extension points:
-
wp_hash_password_algorithmandwp_hash_password_options(6.8) choose algorithm and options; Security Hardening pins the cost and shows Argon2id. -
check_passwordfilters every check result,password_needs_rehashthe rehash decision, and thewp_set_passwordaction (6.2) fires after a password is stored. -
A plugin that sets the global
$wp_hasher(the pre-6.8 way to replace phpass) still wins over bcrypt, andwp_password_needs_rehash()then always returnsfalse; such plugins are worth removing.
The PHP side is in PHP: Cryptography, Hashing and Passwords.
Application passwords from code
Application passwords (5.6) are per-user, revocable credentials for HTTP Basic authentication on the REST API and
XML-RPC; they never work on wp-login.php. They are stored in the _application_passwords user meta as items with
uuid, app_id, name, password, created, last_used and last_ip; since 6.8 password holds a BLAKE2b hash
from wp_fast_hash(), marked $generic$. Availability rules are in
Users, Roles and Profiles. Provisioning code
uses WP_Application_Passwords:
<?php
$user = get_user_by( 'login', 'tom.author' );
$result = WP_Application_Passwords::create_new_application_password(
$user->ID,
array(
'name' => 'Lumen reader app',
'app_id' => '9a1d0c55-3f6e-4b7a-8d2e-6c0f4b1e2a77',
)
);
if ( is_wp_error( $result ) ) {
WP_CLI::error( $result );
}
list( $password, $item ) = $result;
WP_CLI::line( 'Password: ' . WP_Application_Passwords::chunk_password( $password ) );
WP_CLI::line( 'UUID: ' . $item['uuid'] );
WP_CLI::line( 'Stored: ' . substr( $item['password'], 0, 9 ) . '...' );
Password: <24-character application password>
UUID: 75673a15-dca5-489a-b0bc-dc2e38fe216c
Stored: $generic$...
The plain password is returned only once, as the first array element; a missing name gives a WP_Error with status
400. On the test site, curl -u "tom.author:$TOM_APP_PASSWORD" against /wp-json/wp/v2/users/me?context=edit
returned tom.author with the author role; see
REST API: Using It for the request side. Posted to wp-login.php, the same password was rejected as incorrect. The other static methods are
get_user_application_passwords(), get_user_application_password(), update_application_password(),
record_application_password_usage() (writes at most once a day), delete_application_password() and
delete_all_application_passwords(); they fire wp_create_application_password, wp_update_application_password
and wp_delete_application_password. Authentication fires application_password_did_authenticate or
application_password_failed_authentication, and application_password_is_api_request extends the API-only rule.
WP-CLI wraps the class as wp user application-password.
An external app should not ask users to paste a password: it sends them to
/wp-admin/authorize-application.php?app_name=...&app_id=<uuid>&success_url=... (advertised in the REST index under
authentication), and after approval WordPress redirects to success_url with site_url, user_login and
password. A plain-HTTP success_url is refused unless the host is a loopback address or the environment type is
local; custom app schemes are allowed, javascript: and data: are not.
Sessions
Every login creates a session in a WP_Session_Tokens store. The default WP_User_Meta_Session_Tokens keeps the
session_tokens user meta, keyed by the SHA-256 of each token, with the session’s expiration, ip, ua and login
time; expired sessions are filtered out whenever the store is read. attach_session_information adds fields, and
session_token_manager swaps in another class (for example one backed by Redis). The cookie carries the raw token,
the database only its hash:
<?php
$manager = WP_Session_Tokens::get_instance( get_user_by( 'login', 'tom.author' )->ID );
$laptop = $manager->create( time() + 2 * DAY_IN_SECONDS ); // Returns the raw token.
$phone = $manager->create( time() + 14 * DAY_IN_SECONDS );
WP_CLI::line( get_class( $manager ) . ', sessions: ' . count( $manager->get_all() ) );
WP_CLI::line( 'Stored keys: ' . implode( ', ', array_keys( $manager->get( $laptop ) ) ) );
$manager->destroy_others( $laptop ); // "Log Out Everywhere Else" from the laptop.
WP_CLI::line( 'After destroy_others: ' . count( $manager->get_all() ) . ', phone valid: ' . var_export( $manager->verify( $phone ), true ) );
$manager->destroy_all();
WP_CLI::line( 'After destroy_all: ' . count( $manager->get_all() ) );
WP_User_Meta_Session_Tokens, sessions: 4
Stored keys: expiration, ip, ua, login
After destroy_others: 1, phone valid: false
After destroy_all: 0
The count is four because tom.author already had two sessions (the curl login and the cookie example). For the
current user, wp_get_session_token(), wp_get_all_sessions(), wp_destroy_current_session(),
wp_destroy_other_sessions() and wp_destroy_all_sessions() wrap the same methods, and
WP_Session_Tokens::destroy_all_for_all_users() logs everybody out. A password change leaves stored sessions in
place, but their cookies stop validating. From the shell: wp user session destroy <user> --all.
Single sign-on integration points
Core knows only passwords, cookies and application passwords. OpenID Connect, OAuth 2.0 or SAML sign-on comes from plugins, such as OpenID Connect Generic Client on WordPress.org, or from commercial identity suites, which hook into the places described above:
| Integration point | What an SSO plugin does there |
|---|---|
|
Adds a "Sign in with …" button, or redirects |
A callback ( |
Receives the authorization code, exchanges it and validates the ID token |
|
Creates or links the account on first login and maps claims to roles |
|
Starts a normal WordPress session, so nothing else changes |
|
Checks the normal form’s credentials against an external directory (LDAP) |
|
Accepts a bearer token (OAuth access token or JWT) on REST requests instead of a cookie |
|
Ends the provider session too (RP-initiated logout) |
WordPress cookies outlive the provider’s session unless the plugin shortens auth_cookie_expiration, and local
passwords and application passwords keep working beside SSO unless the plugin disables them. The protocols are
explained in OpenID Connect,
Social Login and Federation and
Logout and Session Management.
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
"Cookies are blocked or not supported by your browser" |
No |
Logged out again on the next page |
HTTPS terminated at a proxy without |
A login check is bypassed by API clients |
It is only on |
One user’s page served to another |
The page cache ignores |
Where older books are outdated
| Older claim | Current behaviour (WordPress 7.1) |
|---|---|
Passwords are stored as phpass (MD5-based) hashes |
bcrypt since 6.8 ( |
Reset a lost password by writing an MD5 hash with phpMyAdmin |
Accepted once and rehashed, but |
|
It only repairs tables, and exposes the repair page without login |
Core offers two-factor authentication |
It does not; it comes from a plugin such as Two Factor, or from SSO |
Log in with |
Deprecated since 2.5 and 4.5; use |
Remote apps need the user’s real password or a Basic Auth plugin |
Application passwords are built in since 5.6, hashed with BLAKE2b since 6.8 |
More corrections are collected in Where Older Books and Tutorials Are Outdated.