Back to Blog
Security

WordPress Security Internals: Cookie Authentication, CSRF Protection, and Password Hashing Under the Hood

David Okonkwo
40 min read

How WordPress Actually Authenticates You

WordPress handles millions of authenticated sessions every second across the web. Behind the login screen lies a carefully layered system of cookies, hashed tokens, cryptographic salts, and time-limited nonces. Most WordPress developers interact with this machinery only at arm’s length, calling wp_login_url() or is_user_logged_in() without thinking about the plumbing underneath.

This article pulls the covers off. We will trace the exact path a login takes through WordPress core, examine how cookies are generated and validated, inspect the two-cookie architecture that separates admin access from frontend identity, break down the CSRF protection lifecycle, and study how WordPress hashes passwords. Every function referenced here comes directly from WordPress core source files, primarily wp-includes/pluggable.php, wp-includes/class-wp-session-tokens.php, and wp-includes/capabilities.php.

If you write plugins, build themes that handle user data, or run sites where a breach would hurt, this is the stuff that matters.

Cookie Authentication: What Happens After You Log In

When a user submits valid credentials through wp-login.php, WordPress calls wp_set_auth_cookie(). This function is the single entry point for all cookie-based authentication in core. It lives in wp-includes/pluggable.php, which means any plugin can override it by defining the function before WordPress loads it.

The Cookie Generation Pipeline

Here is the sequence of events inside wp_set_auth_cookie():

1. WordPress determines the cookie expiration. If the user checked “Remember Me,” the expiration is 14 days from now. If they did not, it defaults to 2 days. Both values pass through the auth_cookie_expiration filter, so plugins can change them.

2. WordPress determines whether the connection is over SSL. If it is, the auth cookie name becomes SECURE_AUTH_COOKIE and the scheme is secure_auth. If not, the cookie name is AUTH_COOKIE and the scheme is auth.

3. A session token is created by calling WP_Session_Tokens::get_instance($user_id)->create($expiration). This generates a 43-character random string using wp_generate_password(43, false, false) and stores it (along with session metadata like IP address, user agent, and login timestamp) in user meta.

4. WordPress calls wp_generate_auth_cookie() twice: once for the auth cookie (using the auth or secure_auth scheme) and once for the logged-in cookie (using the logged_in scheme).

5. The cookies are sent to the browser via setcookie() calls. The auth cookie is set for both PLUGINS_COOKIE_PATH and ADMIN_COOKIE_PATH. The logged-in cookie is set for COOKIEPATH and, if different, SITECOOKIEPATH.

Inside wp_generate_auth_cookie()

The actual cookie value is built by wp_generate_auth_cookie(). This function takes four arguments: $user_id, $expiration, $scheme, and $token. Here is what it produces:

First, it extracts a four-character fragment from the user’s hashed password. For passwords hashed with phpass ($P$ prefix) or vanilla bcrypt ($2y$ prefix), it takes characters 8 through 11. For passwords using the newer $wp prefix introduced in WordPress 6.8, it takes the last four characters. This fragment acts as a binding between the cookie and the stored password hash, meaning any password change automatically invalidates all existing cookies.

Then it builds an HMAC key:

$key = wp_hash($username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme);

Using that key, it generates the final HMAC:

$hash = hash_hmac('sha256', $username . '|' . $expiration . '|' . $token, $key);

The resulting cookie value is four pipe-delimited fields:

username|expiration|token|hmac

This structure has specific security properties. The username and expiration are readable in plaintext (they are not secret), but the HMAC binds them together with the session token and password fragment. Tampering with any field invalidates the HMAC. The password fragment is not included in the cookie itself but is used during HMAC generation, so the server can recompute the HMAC during validation.

Inside wp_validate_auth_cookie()

Every authenticated request triggers wp_validate_auth_cookie(). This function is the gatekeeper. Here is its validation sequence:

1. It calls wp_parse_auth_cookie() to split the cookie into its four components: username, expiration, token, and hmac. If the cookie does not contain exactly four pipe-delimited segments, validation fails immediately with the auth_cookie_malformed action.

2. It checks whether the cookie has expired. There is a grace period: if the current request is an AJAX call or a POST request, WordPress adds one hour to the expiration timestamp before comparing. This prevents edge cases where a long form submission or background AJAX poll fails because the cookie expired mid-request. If the cookie has expired beyond the grace period, the auth_cookie_expired action fires and validation fails.

3. It loads the user by login name using get_user_by('login', $username). If no such user exists, the auth_cookie_bad_username action fires.

4. It extracts the same four-character password fragment used during cookie generation.

5. It recomputes the HMAC key and the HMAC hash, then compares the result against the HMAC from the cookie using hash_equals(). This is a timing-safe comparison function, which prevents timing attacks where an attacker measures response times to guess the correct HMAC character by character. If the HMACs do not match, the auth_cookie_bad_hash action fires.

6. Finally, it verifies the session token by calling WP_Session_Tokens::get_instance($user->ID)->verify($token). This hashes the token with SHA-256 and checks if a matching session exists in user meta that has not expired. If the token is invalid, the auth_cookie_bad_session_token action fires.

Only when all six checks pass does the function return the user ID. That user ID is then used to set up $current_user for the rest of the request.

The Two-Cookie System: Auth Cookie vs. Logged-In Cookie

WordPress does not use a single cookie for authentication. It uses two, and the distinction matters.

The Auth Cookie (or Secure Auth Cookie)

The auth cookie is scoped to the admin area. Specifically, wp_set_auth_cookie() sets it with two paths: PLUGINS_COOKIE_PATH (typically /wp-content/plugins) and ADMIN_COOKIE_PATH (typically /wp-admin). If the connection is over HTTPS, this cookie uses the SECURE_AUTH_COOKIE name and the secure_auth scheme. If not, it uses AUTH_COOKIE and the auth scheme.

The auth cookie’s HMAC is computed using the auth or secure_auth salt. This means that even if someone steals the logged-in cookie, they cannot use it to authenticate admin requests because the HMAC will not match when validated with the wrong scheme.

The auth cookie is set with the httponly flag set to true, which prevents JavaScript from reading it. The secure flag depends on whether HTTPS is active.

The Logged-In Cookie

The logged-in cookie is scoped to the entire site. It is set at COOKIEPATH (usually /) so it covers both the frontend and the admin. Its name is the constant LOGGED_IN_COOKIE.

This cookie uses the logged_in scheme, which means its HMAC is computed with different salts than the auth cookie. The secure flag for this cookie is determined separately: it is only set to true when the auth cookie is secure AND the site’s home URL uses HTTPS. This logic lives in wp_set_auth_cookie():

$secure_logged_in_cookie = $secure && 'https' === parse_url(get_option('home'), PHP_URL_SCHEME);

Both flags pass through their own filters (secure_auth_cookie and secure_logged_in_cookie), allowing plugins to override the behavior.

Why Two Cookies?

The separation exists for a specific reason. The logged-in cookie tells WordPress “this visitor is user X” for frontend purposes: displaying a toolbar, personalizing content, showing user-specific data. But it does not grant admin access.

If an attacker somehow intercepts the logged-in cookie (for example, on an HTTP page that loads mixed content), they can impersonate the user on the frontend but cannot access wp-admin or perform administrative actions, because those paths require the auth cookie, which was only sent over HTTPS with a stricter scope.

This defense-in-depth approach limits the blast radius of a cookie theft. It is one of the strongest architectural security decisions in WordPress core.

Secret Keys and Salts: The Foundation of Cookie Security

Every HMAC computation in the cookie system depends on salts retrieved by wp_salt(). This function is the cryptographic backbone of WordPress authentication.

How wp_salt() Works

The wp_salt() function accepts a single parameter: the scheme. Valid schemes are auth, secure_auth, logged_in, and nonce. For each scheme, WordPress looks for two values: a key and a salt.

For the auth scheme, it looks for AUTH_KEY and AUTH_SALT. For secure_auth, it looks for SECURE_AUTH_KEY and SECURE_AUTH_SALT. And so on. These constants are typically defined in wp-config.php.

The function first checks whether each constant is defined, non-empty, and not a duplicate of another constant. WordPress explicitly detects duplicated values and the default placeholder string 'put your unique phrase here'. If any key or salt is undefined, duplicated, or still set to the default placeholder, WordPress falls back to a database-stored value. If that does not exist either, WordPress generates a random 64-character string using wp_generate_password(64, true, true) and stores it as a site option.

The final salt for any given scheme is the concatenation of the key and the salt: $key . $salt. This combined string is then used as the key in hash_hmac() calls throughout the authentication system.

For unknown or custom schemes, wp_salt() uses SECRET_KEY as the key and computes the salt as hash_hmac('md5', $scheme, $values['key']).

Where wp_salt() Gets Called

The wp_hash() function is the primary consumer of wp_salt(). Its signature is wp_hash($data, $scheme = 'auth', $algo = 'md5'). It calls wp_salt($scheme) to get the salt, then returns hash_hmac($algo, $data, $salt). As of WordPress 6.8, the default algorithm is md5, but it accepts any algorithm supported by PHP’s hash_hmac().

This means that when wp_generate_auth_cookie() calls wp_hash($username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme), the scheme parameter controls which salt is used. The auth scheme uses AUTH_KEY and AUTH_SALT. The logged_in scheme uses LOGGED_IN_KEY and LOGGED_IN_SALT. Different keys for different cookies.

Rotation Implications

Changing any secret key or salt in wp-config.php immediately invalidates all cookies that were computed using the old values. The HMAC recomputation will produce a different result, and wp_validate_auth_cookie() will reject the cookie.

This is actually a useful security tool. If you suspect a breach, rotating all eight constants (AUTH_KEY, AUTH_SALT, SECURE_AUTH_KEY, SECURE_AUTH_SALT, LOGGED_IN_KEY, LOGGED_IN_SALT, NONCE_KEY, NONCE_SALT) forces every user to log in again and invalidates all existing nonces. WordPress provides a generator at https://api.wordpress.org/secret-key/1.1/salt/ specifically for this purpose.

But rotation comes with side effects. Any user in the middle of a form submission will have their nonce invalidated. Any AJAX request in flight will fail. Scheduled cron tasks that rely on authenticated sessions may break. The recommended practice is to rotate keys during a maintenance window and communicate to users that they will need to log in again.

You can also rotate selectively. Changing only NONCE_KEY and NONCE_SALT invalidates nonces without affecting auth cookies. Changing only AUTH_KEY and AUTH_SALT invalidates admin cookies but leaves logged-in cookies intact (though a user would lose admin access until they log in again, their frontend session would still work).

Password Hashing: From phpass to bcrypt

WordPress 6.8 marked a significant change in password hashing. Prior to that release, WordPress used the phpass library, a PHP password hashing framework that implements a modified version of the Blowfish-based portable hash. Since WordPress 6.8, the default hashing algorithm is bcrypt, with a specific WordPress prefix scheme.

How wp_hash_password() Works

The wp_hash_password() function takes a plaintext password and returns a hashed string. Here is the algorithm:

First, if a global $wp_hasher object exists (set by a plugin that wants to override hashing), WordPress uses that hasher’s HashPassword() method. This backward-compatibility path preserves support for plugins that replaced the hashing mechanism before WordPress 6.8.

If no custom hasher is present and the password exceeds 4,096 characters, the function returns '*' (an invalid hash that will never verify). This length limit protects against denial-of-service attacks where an attacker submits extremely long passwords to consume server resources during hashing.

For bcrypt hashing, the algorithm and options are filterable via wp_hash_password_algorithm and wp_hash_password_options. The default algorithm is PASSWORD_BCRYPT.

If the algorithm is not bcrypt (for example, PASSWORD_ARGON2ID), WordPress calls PHP’s password_hash() directly with no pre-processing.

For bcrypt specifically, WordPress applies pre-hashing. Bcrypt has a 72-byte input limit. Passwords longer than 72 bytes would be silently truncated, which means two passwords that differ only after the 72nd byte would hash identically. To avoid this, WordPress pre-hashes the password:

$password_to_hash = base64_encode(hash_hmac('sha384', trim($password), 'wp-sha384', true));

This produces a fixed-length base64 string that preserves the full entropy of the original password regardless of its length. The HMAC key 'wp-sha384' is a hardcoded domain separator that prevents cross-protocol attacks.

The final hash is prefixed with '$wp' to distinguish it from vanilla bcrypt hashes:

return '$wp' . password_hash($password_to_hash, $algorithm, $options);

A WordPress 6.8+ password hash looks like: $wp$2y$10$... where $wp is the WordPress prefix, $2y$ is the bcrypt identifier, and 10 is the cost factor.

How wp_check_password() Works

Password verification in wp_check_password() handles multiple hash formats because WordPress must remain backward-compatible with passwords hashed under older systems:

1. If the stored hash is 32 characters or fewer, WordPress treats it as a plain MD5 hash and compares md5($password) against it using hash_equals(). This handles very old WordPress passwords from versions before 2.5.

2. If a global $wp_hasher exists, it uses that hasher’s CheckPassword() method.

3. If the password exceeds 4,096 characters, the check automatically fails.

4. If the stored hash starts with '$wp', WordPress applies the same pre-hashing as wp_hash_password() and then calls password_verify() on the hash with the prefix stripped:

$password_to_verify = base64_encode(hash_hmac('sha384', $password, 'wp-sha384', true));
$check = password_verify($password_to_verify, substr($hash, 3));

5. If the stored hash starts with '$P$', WordPress loads the phpass library and uses it to verify.

6. For any other hash format (like vanilla bcrypt $2y$), WordPress falls back to password_verify() directly.

Automatic Rehashing

WordPress 6.8 also introduced wp_password_needs_rehash(). After a successful password verification, WordPress checks whether the stored hash uses the current algorithm. If a user’s password is still stored as a phpass hash and they log in successfully, WordPress automatically rehashes their password with bcrypt and updates the database. This transparent upgrade means that over time, all active users migrate to the stronger algorithm without any action on their part.

The wp_password_needs_rehash() function checks two conditions: if bcrypt is the current algorithm and the hash lacks the '$wp' prefix, it needs rehashing. Otherwise, it delegates to PHP’s password_needs_rehash() to check if the cost factor or algorithm has changed.

CSRF Protection: The Nonce Lifecycle

WordPress nonces are not actually “number used once.” The name is borrowed from cryptography, but WordPress nonces are time-limited tokens, not single-use tokens. A WordPress nonce can be used multiple times within its validity window. Understanding this distinction is critical for writing secure plugins.

Nonce Creation with wp_create_nonce()

The wp_create_nonce() function produces a 10-character string derived from four inputs:

1. A tick value from wp_nonce_tick(), which divides the current time into half-day windows
2. The action string (a developer-provided label describing what the nonce protects)
3. The current user’s ID
4. The current session token (from wp_get_session_token())

These four values are concatenated with pipe delimiters:

$i . '|' . $action . '|' . $uid . '|' . $token

This string is then hashed using wp_hash() with the nonce scheme, and the nonce is extracted as a 10-character substring: substr(wp_hash(..., 'nonce'), -12, 10).

Because the tick value changes every 12 hours (half the nonce lifetime), the nonce value rotates automatically. A nonce created at 11:59 AM will differ from one created at 12:01 PM if they fall in different tick windows.

The Tick System

The wp_nonce_tick() function computes:

ceil(time() / ($nonce_life / 2))

The default nonce lifetime is DAY_IN_SECONDS (86,400 seconds), so the tick changes every 43,200 seconds (12 hours). The lifetime is filterable via the nonce_life filter.

This tick system is why wp_verify_nonce() returns different integer values depending on the nonce’s age. A return value of 1 means the nonce was generated in the current tick window (0-12 hours ago). A return value of 2 means it was generated in the previous tick window (12-24 hours ago). A return value of false means the nonce is invalid or expired.

Embedding Nonces in Forms and URLs

WordPress provides two primary functions for embedding nonces:

wp_nonce_field($action, $name, $referer, $display) generates a hidden form field:

<input type="hidden" id="_wpnonce" name="_wpnonce" value="a1b2c3d4e5" />

If the $referer parameter is true (the default), it also adds a _wp_http_referer hidden field containing the current request URI. This referer field is used by check_admin_referer() for additional validation.

wp_nonce_url($actionurl, $action, $name) appends the nonce as a query parameter to a URL:

https://example.com/wp-admin/edit.php?_wpnonce=a1b2c3d4e5

For AJAX requests, the standard practice is to pass the nonce in the request body or as a custom header. WordPress AJAX handlers typically look for $_REQUEST['_ajax_nonce'] or $_REQUEST['_wpnonce'].

Nonce Verification

Three functions handle nonce verification:

wp_verify_nonce($nonce, $action) is the low-level verification function. It recomputes the expected nonce using the current tick and the previous tick, then compares both against the provided nonce using hash_equals(). If the current tick matches, it returns 1. If the previous tick matches, it returns 2. Otherwise, it fires the wp_verify_nonce_failed action and returns false.

check_admin_referer($action, $query_arg) is designed for form submissions in the admin area. It reads the nonce from $_REQUEST[$query_arg] (defaulting to _wpnonce) and calls wp_verify_nonce(). If verification fails AND the action is not the default -1 AND the referer does not start with the admin URL, it calls wp_nonce_ays($action) which displays the “Are you sure you want to do this?” error page and terminates execution.

check_ajax_referer($action, $query_arg, $stop) is the AJAX equivalent. It looks for the nonce in three places, in order: the specified $query_arg, $_REQUEST['_ajax_nonce'], or $_REQUEST['_wpnonce']. If verification fails and $stop is true (the default), it calls wp_die(-1, 403) for AJAX contexts or die('-1') otherwise.

Why Nonces Are Tied to Users and Sessions

A WordPress nonce includes the user ID and session token in its computation. This means that a nonce generated for User A cannot be used by User B, even if User B knows the action string. It also means that a nonce from one browser session is invalid in another session, even for the same user. If a user logs out and logs back in, their session token changes, and all their old nonces become invalid.

This session-binding is what makes WordPress nonces effective against CSRF. An attacker would need to know the victim’s session token to forge a valid nonce, and the session token is only available inside the victim’s auth cookie, which the attacker cannot read (thanks to the httponly flag).

Capability Checks vs. Nonce Checks: When Each Is Needed

A common source of confusion in plugin development is the difference between capability checks and nonce checks. Both are required for secure operations, but they protect against different threats.

Capability Checks: Authorization

Capability checks answer the question: “Is this user allowed to do this?” They verify that the current user has a specific permission. The primary function is current_user_can($capability, ...$args), which internally calls user_can(wp_get_current_user(), $capability, ...$args).

WordPress has two types of capabilities:

Primitive capabilities are directly assigned to roles. Examples: edit_posts, manage_options, delete_users. An administrator has manage_options, a subscriber does not.

Meta capabilities are contextual and get mapped to primitive capabilities through map_meta_cap(). For example, edit_post (singular) is a meta capability. When you call current_user_can('edit_post', $post_id), WordPress maps it to the appropriate primitive capabilities based on whether the user owns the post, whether the post is published, and other factors. An author can edit_post on their own published post (maps to edit_published_posts) but not on another author’s post (would require edit_others_posts).

Missing capability checks lead to privilege escalation vulnerabilities. If an AJAX handler deletes a post without checking current_user_can('delete_post', $post_id), any logged-in user (even a subscriber) could delete any post.

Nonce Checks: Intent Verification

Nonce checks answer a different question: “Did this user intentionally initiate this action?” They protect against CSRF attacks, where an attacker tricks a user’s browser into making a request the user did not intend.

Consider an admin panel that deletes a user when you visit /wp-admin/user-delete.php?user_id=5. Without a nonce, an attacker could embed an image tag on their website: <img src="https://victim.com/wp-admin/user-delete.php?user_id=5">. When an admin visits the attacker’s site, their browser sends the request along with their auth cookies, and the user gets deleted.

With a nonce, that URL becomes /wp-admin/user-delete.php?user_id=5&_wpnonce=abc123. The attacker cannot predict abc123 because it depends on the admin’s user ID, session token, and the current time tick. The forged request fails nonce verification.

Both Are Required

Secure WordPress operations always need both checks. Here is the correct pattern for an AJAX handler:

add_action('wp_ajax_my_delete_item', function() {
    // Step 1: Verify the nonce (CSRF protection)
    check_ajax_referer('my_delete_item_nonce', 'security');

    // Step 2: Verify the capability (authorization)
    if (!current_user_can('delete_posts')) {
        wp_send_json_error('Insufficient permissions.', 403);
    }

    // Step 3: Sanitize input
    $item_id = absint($_POST['item_id']);

    // Step 4: Perform the action
    wp_delete_post($item_id, true);
    wp_send_json_success('Item deleted.');
});

Skipping the nonce check means the handler is vulnerable to CSRF. Skipping the capability check means any logged-in user can trigger the action. Skipping both is a critical vulnerability.

A common mistake is to assume that nonce checks implicitly verify capability. They do not. A nonce confirms the request came from a particular user’s browser willingly. It says nothing about whether that user should be allowed to perform the action. A subscriber’s valid nonce does not grant them admin powers.

Session Management: WP_Session_Tokens

WordPress 4.0 introduced a formal session management system via the abstract WP_Session_Tokens class. Before this, WordPress had no concept of individual sessions. Changing your password was the only way to invalidate a login.

How Sessions Are Stored

The default implementation is WP_User_Meta_Session_Tokens, which stores sessions in the session_tokens user meta key. Each session entry contains:

expiration: Unix timestamp when the session expires
ip: The IP address that created the session
ua: The user agent string
login: The Unix timestamp of when the session was created

The session token itself (the 43-character random string) is never stored directly. Instead, WordPress stores a SHA-256 hash of the token, called the “verifier.” When validating a cookie, WordPress hashes the token from the cookie and looks for a matching verifier in user meta. This means that even if an attacker reads the database, they get verifiers, not tokens. They cannot reconstruct a valid cookie from a verifier because SHA-256 is a one-way function.

Session Operations

The WP_Session_Tokens class provides these key operations:

create($expiration) generates a new 43-character token, records session metadata (IP, user agent, login time), and stores the SHA-256 verifier.

verify($token) hashes the provided token and checks if a non-expired session with that verifier exists.

destroy($token) removes a specific session by setting its stored data to null. This is called during logout.

destroy_others($token_to_keep) removes all sessions except the specified one. This is what powers the “Log Out Everywhere Else” button on the user profile page.

destroy_all() removes every session for the user, forcing a complete re-authentication.

destroy_all_for_all_users() is a static method that wipes all sessions for every user. This is a nuclear option, useful during a suspected site-wide compromise.

Pluggable Session Backends

The session_token_manager filter allows plugins to replace the default user-meta-based storage with alternatives. Common replacements include Redis-backed session stores or database table stores. The abstract class defines the interface; any subclass must implement get_sessions(), get_session($verifier), update_session($verifier, $session), destroy_other_sessions($verifier), destroy_all_sessions(), and drop_sessions().

For high-traffic sites, the default user meta storage can become a bottleneck because every session validation requires a database query against the wp_usermeta table. A Redis-backed implementation can reduce authentication latency significantly.

Concurrent Session Limits

WordPress core does not impose a limit on concurrent sessions. A user can log in from 50 different browsers simultaneously, and all 50 sessions will be valid. This is a deliberate design choice that favors usability over strict security.

Plugins that want to enforce concurrent session limits can hook into the attach_session_information filter (which fires during session creation) to count existing sessions and destroy older ones. The typical approach is:

add_filter('attach_session_information', function($session, $user_id) {
    $manager = WP_Session_Tokens::get_instance($user_id);
    $sessions = $manager->get_all();
    if (count($sessions) >= 3) {
        // Sort by login time and destroy the oldest
        usort($sessions, function($a, $b) {
            return $a['login'] - $b['login'];
        });
        // Destroy excess sessions by recreating only the allowed ones
        $manager->destroy_all();
    }
    return $session;
}, 10, 2);

This is a simplified example. Production implementations need to handle race conditions and ensure the current session is not destroyed.

Common Security Mistakes in Plugins

Security vulnerabilities in WordPress almost always originate in plugins and themes, not in core. Here are the mistakes that appear most frequently in vulnerability reports.

Missing Nonce Verification on AJAX Handlers

This is the most common plugin vulnerability. A developer registers an AJAX action but forgets to call check_ajax_referer():

// VULNERABLE: No nonce check
add_action('wp_ajax_delete_record', function() {
    global $wpdb;
    $id = intval($_POST['id']);
    $wpdb->delete('my_table', ['id' => $id]);
    wp_send_json_success();
});

An attacker can create a page that submits a POST request to /wp-admin/admin-ajax.php with action=delete_record&id=1. Any logged-in user who visits the attacker’s page will unknowingly delete the record.

Missing Capability Checks

Even with a nonce, the handler above is still vulnerable to privilege escalation because any logged-in user (including subscribers) can trigger it. Always pair nonce checks with capability checks:

add_action('wp_ajax_delete_record', function() {
    check_ajax_referer('delete_record_nonce', 'security');
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Forbidden', 403);
    }
    global $wpdb;
    $id = absint($_POST['id']);
    $wpdb->delete('my_table', ['id' => $id]);
    wp_send_json_success();
});

Using $_GET or $_POST Without Sanitization

Raw superglobal access without sanitization leads to SQL injection, XSS, and other injection attacks:

// VULNERABLE: Direct use of unsanitized input in a query
$name = $_POST['name'];
$wpdb->query("UPDATE my_table SET name = '$name' WHERE id = 1");

The fix involves both sanitization and prepared statements:

$name = sanitize_text_field($_POST['name']);
$wpdb->update('my_table', ['name' => $name], ['id' => 1], ['%s'], ['%d']);

Or with $wpdb->prepare():

$name = sanitize_text_field($_POST['name']);
$wpdb->query($wpdb->prepare(
    "UPDATE my_table SET name = %s WHERE id = %d",
    $name,
    1
));

Unescaped Output

Data displayed in HTML must be escaped according to its context:

// VULNERABLE: Unescaped output
echo '<a href="' . $url . '">' . $title . '</a>';

// CORRECT: Context-appropriate escaping
echo '<a href="' . esc_url($url) . '">' . esc_html($title) . '</a>';

WordPress provides context-specific escaping functions: esc_html() for HTML content, esc_attr() for HTML attributes, esc_url() for URLs, esc_js() for inline JavaScript, and esc_textarea() for textarea content. Using the wrong escaping function for the context can still leave vulnerabilities.

Direct File Inclusion Based on User Input

Some plugins dynamically include PHP files based on request parameters:

// VULNERABLE: Local File Inclusion
$template = $_GET['template'];
include(PLUGIN_DIR . '/templates/' . $template . '.php');

An attacker can use path traversal (../../wp-config) to include arbitrary files. The fix is to whitelist allowed values:

$allowed = ['dashboard', 'settings', 'reports'];
$template = sanitize_file_name($_GET['template']);
if (!in_array($template, $allowed, true)) {
    wp_die('Invalid template.');
}
include(PLUGIN_DIR . '/templates/' . $template . '.php');

Storing Sensitive Data in Options Without Encryption

Plugins that store API keys, tokens, or credentials in the options table using update_option() leave that data readable to any code with database access, including other plugins. While WordPress core does not provide an encryption API, sensitive data should at minimum be encrypted before storage using openssl_encrypt() with a key derived from the auth salts.

Ignoring the nopriv Hook for Public Endpoints

AJAX handlers registered only with wp_ajax_ are accessible only to logged-in users. If a handler needs to be publicly accessible, it must also be registered with wp_ajax_nopriv_. But the reverse mistake is worse: registering a sensitive handler with both hooks when it should only be available to authenticated users.

// DANGEROUS: Admin action exposed to unauthenticated users
add_action('wp_ajax_nopriv_admin_reset', 'handle_admin_reset');
add_action('wp_ajax_admin_reset', 'handle_admin_reset');

Only use wp_ajax_nopriv_ for actions that are genuinely intended for unauthenticated users, like newsletter signups or public search queries.

Security Headers WordPress Should Send (But Does Not by Default)

WordPress core sends a handful of security-related headers, but it omits several that modern browsers support. Understanding which headers are missing helps you harden your site.

What WordPress Does Send

WordPress sends the X-Content-Type-Options: nosniff header for certain responses, which prevents browsers from MIME-sniffing a response away from the declared content type. It also sends X-Frame-Options for login and admin pages through send_frame_options_header(), which outputs X-Frame-Options: SAMEORIGIN to prevent clickjacking.

For the REST API, WordPress sends X-Content-Type-Options: nosniff and sets appropriate Cache-Control headers.

Content-Security-Policy (CSP)

WordPress does not send a Content-Security-Policy header. This is arguably the most impactful missing header. CSP tells the browser which sources of content are legitimate, blocking inline scripts and unauthorized external resources.

The reason WordPress omits CSP is pragmatic: the WordPress ecosystem relies heavily on inline scripts, inline styles, and dynamic script injection. A strict CSP would break most plugins and many themes. However, you can add a CSP header tailored to your specific site:

add_action('send_headers', function() {
    if (is_admin()) {
        return; // Admin area CSP is harder to configure
    }
    header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . wp_create_nonce('csp') . "'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';");
});

This is a starting point. You will need to adjust the policy based on which external scripts and styles your site loads.

Strict-Transport-Security (HSTS)

WordPress does not send HSTS headers even when the site runs entirely on HTTPS. HSTS tells browsers to always use HTTPS for future requests, preventing SSL-stripping attacks. Adding it is straightforward:

add_action('send_headers', function() {
    if (is_ssl()) {
        header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
    }
});

The max-age value (in seconds) tells the browser how long to remember the HTTPS-only policy. The includeSubDomains directive extends the policy to all subdomains. The preload directive allows the domain to be included in browser HSTS preload lists.

Be careful with HSTS. Once a browser receives this header, it will refuse to connect over HTTP for the specified duration. If you later lose your SSL certificate or need to revert to HTTP, users will be locked out until the max-age expires or they manually clear their browser’s HSTS cache.

Permissions-Policy

The Permissions-Policy header (formerly Feature-Policy) controls which browser features your site can use. WordPress does not set this header. A reasonable default:

header("Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()");

This disables camera, microphone, geolocation, and other sensitive APIs that a WordPress site typically does not need. If an XSS vulnerability is exploited, the attacker cannot access these APIs because the browser will block them at the policy level.

Referrer-Policy

WordPress does not set a Referrer-Policy header. The browser default varies, but modern browsers use strict-origin-when-cross-origin. For sites that want stricter control:

header('Referrer-Policy: strict-origin-when-cross-origin');

This sends the full URL as the referer for same-origin requests but only the origin (domain) for cross-origin requests. It prevents leaking full page paths to external sites.

X-Permitted-Cross-Domain-Policies

This header controls whether Flash and PDF viewers can load data from your domain. WordPress does not set it. Since Flash is dead, this is mainly relevant for PDF viewers:

header('X-Permitted-Cross-Domain-Policies: none');

A Complete Headers Function

Here is a function that adds all the missing headers. Place it in your theme’s functions.php or in a must-use plugin:

add_action('send_headers', function() {
    // HSTS (only on HTTPS)
    if (is_ssl()) {
        header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
    }

    // Prevent MIME sniffing
    header('X-Content-Type-Options: nosniff');

    // Clickjacking protection (supplement WordPress default)
    header('X-Frame-Options: SAMEORIGIN');

    // XSS filter (legacy, but still useful for older browsers)
    header('X-XSS-Protection: 1; mode=block');

    // Referrer policy
    header('Referrer-Policy: strict-origin-when-cross-origin');

    // Permissions policy
    header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');

    // Cross-domain policies
    header('X-Permitted-Cross-Domain-Policies: none');
});

Building a Security Audit Checklist for WordPress Sites

Whether you are reviewing your own site or auditing a client’s, a structured checklist catches issues that scattered testing misses. The following sections cover the areas that matter most, ordered by impact.

Configuration Audit

Secret keys and salts. Open wp-config.php and verify that all eight constants are defined with unique, random values. If you see 'put your unique phrase here' anywhere, the site is using generated fallback keys from the database, which works but is less secure because database-stored keys can be leaked through SQL injection.

Database table prefix. Check that $table_prefix is not the default 'wp_'. While changing the prefix is not strong security on its own, it deflects automated SQL injection attacks that assume the default prefix.

Debug mode. Confirm that WP_DEBUG is false in production. When debug mode is on and WP_DEBUG_DISPLAY is true, PHP errors display on-screen, potentially leaking file paths, database credentials, and internal logic to visitors.

File editing. Check whether DISALLOW_FILE_EDIT is set to true. This constant disables the Theme Editor and Plugin Editor in the admin panel. If an attacker gains admin access, they cannot inject PHP through the built-in editors.

Automatic updates. Review the status of automatic updates. WordPress enables minor (security) auto-updates by default, but some hosts or plugins disable them. The relevant constants are WP_AUTO_UPDATE_CORE and the auto_update_plugin and auto_update_theme filters.

Authentication Audit

Password strength. Check whether the site enforces strong passwords. WordPress core does not enforce password strength on the backend; it only shows a strength meter. Plugins that enforce minimum complexity should be in place for multi-user sites.

Two-factor authentication. Verify whether 2FA is available and, for admin accounts, mandatory. WordPress core does not include 2FA, so this requires a plugin.

Login attempt limiting. WordPress core does not rate-limit login attempts. Without a limit, an attacker can brute-force passwords indefinitely. Verify that a plugin or server-level rule (fail2ban, Cloudflare rate limiting) is active.

Session management. Check the number of active sessions for admin users. You can query this through WP_Session_Tokens::get_instance($user_id)->get_all(). An admin with sessions from multiple countries is a red flag.

Cookie security flags. If the site runs on HTTPS, verify that auth cookies are set with the secure flag. Inspect the response headers from wp-login.php after a successful login. The Set-Cookie header for wordpress_sec_* should include ; secure; httponly.

Plugin and Theme Audit

Update status. List all installed plugins and themes with version numbers. Cross-reference against the WordPress.org plugin repository for available updates. Outdated plugins with known vulnerabilities are the number one attack vector for WordPress sites.

Abandoned plugins. Check the “Last Updated” date for each plugin on wordpress.org. Plugins not updated in over two years are likely abandoned and may contain unpatched vulnerabilities.

Plugin code review. For custom or premium plugins not available on wordpress.org, perform a targeted code review. Search for:
– Direct database queries without $wpdb->prepare()
$_GET, $_POST, or $_REQUEST used without sanitization functions
echo statements without esc_html(), esc_attr(), or esc_url()
– AJAX handlers missing check_ajax_referer() or check_admin_referer()
– AJAX handlers missing current_user_can()
eval(), assert(), preg_replace() with the e modifier, or create_function()
file_get_contents() or curl calls with user-controlled URLs (SSRF)

File permissions. Verify that wp-config.php is not world-readable. On most hosts, it should be 440 or 400. The wp-content/uploads directory should be writable by the web server but not executable for PHP files. Check for .htaccess or nginx rules that block PHP execution in the uploads directory.

Database Audit

User accounts. List all users with the Administrator role. Remove or downgrade any that are not actively needed. Check for unknown administrator accounts, which may indicate a past compromise.

Stored transients. Search for suspicious transient values in the wp_options table. Malware sometimes stores encoded payloads in transients.

Unauthorized options. Look for options with names that do not correspond to core, installed plugins, or installed themes. Backdoors sometimes create options to persist configuration.

Server-Level Audit

PHP version. Verify the site runs PHP 8.0 or later. Older PHP versions have known vulnerabilities and do not receive security patches.

HTTPS configuration. Test the SSL certificate with a tool like SSL Labs. Verify that the certificate is valid, not expired, and that the server supports TLS 1.2 or 1.3 (older versions have known weaknesses).

Security headers. Use a tool or browser developer tools to verify the headers listed in the previous section are present.

Directory listing. Verify that directory listing is disabled. Visit /wp-content/, /wp-includes/, and /wp-content/uploads/ in a browser. If you see a file listing instead of a 403 error, the server is exposing the directory structure.

XML-RPC. If not needed (and it usually is not for modern sites that use the REST API), disable xmlrpc.php. XML-RPC supports system.multicall, which allows an attacker to test hundreds of passwords in a single HTTP request, bypassing most login rate-limiting plugins.

add_filter('xmlrpc_enabled', '__return_false');

REST API exposure. Check which REST API endpoints are publicly accessible. By default, the users endpoint (/wp-json/wp/v2/users) exposes usernames of anyone who has published a post. This gives attackers valid usernames for brute-force attacks. You can restrict this endpoint:

add_filter('rest_endpoints', function($endpoints) {
    if (isset($endpoints['/wp/v2/users'])) {
        unset($endpoints['/wp/v2/users']);
    }
    if (isset($endpoints['/wp/v2/users/(?P<id>[\\d]+)'])) {
        unset($endpoints['/wp/v2/users/(?P<id>[\\d]+)']);
    }
    return $endpoints;
});

Backup and Recovery Audit

Backup frequency. Verify that automated backups run at least daily for the database and at least weekly for files. Check that backups actually work by performing a test restore.

Backup storage. Backups should be stored off-server. A backup sitting in /wp-content/backups/ on the same server is useless if the server is compromised or its disk fails.

Recovery plan. Document the steps needed to restore the site from backup. Include the time estimate. Run through the process at least once per year to verify it works.

Monitoring Audit

File integrity monitoring. A tool or plugin should detect unauthorized changes to core files, plugin files, and theme files. WordPress core includes a Site Health check that compares core file checksums, but it does not cover plugins or themes.

Login monitoring. Track failed and successful login attempts. An unusually high number of failed attempts from a single IP indicates a brute-force attack. A successful login from an unexpected IP or at an unusual time warrants investigation.

Uptime monitoring. An external uptime monitor detects outages that may indicate an ongoing attack (DDoS) or a successful compromise that broke the site.

Pulling It All Together

WordPress security is not a single mechanism but an interconnected system. Cookie authentication depends on salts. Salts protect nonces. Nonces guard against CSRF. Capability checks prevent privilege escalation. Session tokens bind cookies to individual browser sessions. Password hashing protects stored credentials even if the database leaks.

Each layer compensates for weaknesses in the others. If an attacker obtains a cookie, the session token system lets you revoke it without changing the password. If a password is compromised, the two-cookie architecture limits what the attacker can access. If a plugin has an XSS vulnerability, CSP headers and nonce-scoped actions limit the damage.

The practical takeaway for WordPress developers: treat security as a series of specific, verifiable steps rather than an abstract goal. Check the nonce. Check the capability. Sanitize the input. Escape the output. Use prepared queries. Set the headers. Audit the plugins. Verify the backups.

Every function described in this article is part of the public WordPress API and available for inspection in core source. Reading the implementations in wp-includes/pluggable.php and wp-includes/class-wp-session-tokens.php is one of the most effective ways to build a deep understanding of how WordPress protects (and sometimes fails to protect) the sites that run on it.

Share this article

David Okonkwo

Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.