How WordPress uses Authentication Cookies & Sessions: A technical Deep-Dive

| in


Authentication cookies & sessions is easily one of the most misunderstood and badly documented topics in WordPress security.

There’s no shortage of SEO-optimized crap articles that try to explain how WordPress manages sessions, but 95% are flat out wrong, the other 5% are 10 years old and outdated.

If you previously researched this topic, you might have come across statements like these:

WordPress Core is stateless and does not use “sessions”—it uses session cookies.

WordPress does not save server-side data, everything is stored in session cookies.

WordPress uses salts to hash your password in your [session] cookie.

WordPress’s authentication cookies can be stolen via cross-site scripting (XSS).

All of the above are false to varying degrees.

In this deep-dive, we’ll explain how WordPress session management works and what its problems are.

We’ll use the WordPress core code as our single (and only) source of truth.

Basic Concepts

We assume some technical proficiency and won’t delve into the basics of HTTP, SSL, or cookies. However, it’s essential to clarify certain terms within the context of this article for a precise understanding.

You can skip straight to the technical part.

Session

Sessions provide a way to store information about a user across multiple HTTP requests.

Consider a session to be a container that stores arbitrary data such as UI preferences, form validation errors & most commonly, authentication information.

A session always belongs to one user account. However, one user can have many sessions (from different devices).

A session is usually persisted server-side, either in a database, the file system or a cache like Redis or Memcached. This store is sometimes referred to as “session backend”.

To retrieve the correct data for a request, a session identifier (session ID) is needed to uniquely identify a server-side session.

Stateless session

A stateless session stores all information on the client (the user’s browser).

Statelessness refers to the absence of data storage on the back end.

The information in the session can still change (confusing, we know), but is limited in size (the upper limit typically being the maximum HTTP cookie size of 4096 bytes).

Since all information is stored on the client, there needs to be a way to ensure that nobody can tamper with the information in session.

JSON Web Tokens (JWTs) are commonly used as a “secure” method to prevent tampering.

Stateless sessions offer a mix of advantages and disadvantages when compared to traditional server-backed sessions, each significant in different contexts.

Session ID

Every session is identified by a unique session ID—a lengthy, randomly generated string of characters.

This ID is also known as a “session token” or “session key.”

HTTP is stateless, and each request is independent. Thus, the server, and client (user’s browser) need a method to exchange the session ID across requests.

The most common approach is embedding the session ID within a cookie. Less frequent alternatives are using URL parameters (?session_id=XYZ) or hidden form fields.

PHP sessions

PHP offers a built-in session management system that encompasses all these elements. Session IDs are transferred via cookies, and session data is (by default) stored in the file system.

Example usage:

<?php

session_start();

if (empty($_SESSION['count'])) {
   $_SESSION['count'] = 1;
} else {
   $_SESSION['count']++;
}
?>

<p>
You have seen this page <?php echo $_SESSION['count']; ?> times.
</p>

Despite their ease of use, PHP Sessions have design problems.

Some WordPress hosts, especially shared ones, disable native PHP Sessions, and WordPress Core doesn’t utilize them.

However, many plugins do.

Authentication

While there are multiple ways for a user to verify identity, web applications like WordPress primarily rely on usernames and passwords.

The challenge is maintaining the user’s authenticated state across requests.

Sessions are ideal for this purpose. Here’s the typical flow:

  1. The user successfully enters their credentials.
  2. A new session is initiated, storing the user’s account ID.
  3. The server issues a cookie containing this session ID.
  4. Future requests from the user do not require re-authentication.

Authentication (with user interaction) is usually a one-time event that happens before the web application creates a session.

WordPress calls session cookies “auth cookies”.

We’ll stick to this language, as WordPress embeds more information in the “auth cookie” than just the session ID.

Put simply, a valid auth cookie allows you to access the site without the need for re-authentication.

Exploration of WordPress’s Session Management

At a very high-level, WordPress uses an unorthodox and somewhat convoluted combination of database-backed Sessions and a custom signature-based scheme that resembles JWTs.

Session creation

The entry point for the session management is the wp_set_auth_cookie function, which WordPress Core calls after a user provides their correct credentials.

function wp_set_auth_cookie($user_id, $remember = false, $secure = '', $token = '') {
    // CUT FOR BREVITY
    
    if ($remember) {
        $expiration = time() + apply_filters('auth_cookie_expiration', 14 * DAY_IN_SECONDS, $user_id, $remember);
    } else {
        $expiration = time() + apply_filters('auth_cookie_expiration', 2 * DAY_IN_SECONDS, $user_id, $remember);
    }
    
    // CUT FOR BREVITY
    
    if ('' === $token) {
        $manager = WP_Session_Tokens::get_instance($user_id);
        $token = $manager->create($expiration);
    }
    
    $auth_cookie = wp_generate_auth_cookie($user_id, $expiration, $scheme, $token);
    $logged_in_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $token);
    
    // COOKIE is sent to the client here via setcookie()
}

We have removed irrelevant details from the code (see the 100+ lines here) and we’re focusing on the three most relevant parts.

Determining the session expiration (line 4-8)

A session in WordPress has a fixed expiration timestamp, after which the user has to re-authenticate.

There’s no way to extend this timeout after session creation. This is referred to as an absolute timeout.

By default, the session duration is two days, fourteen for users that clicked the “remember me” checkbox.

The session duration can be customized via the auth_cookie_expiration filter:

<?php

add_filter('auth_cookie_expiration', fn() :int => HOUR_IN_SECONDS);

Creating a session & session ID (line 12-15)

Old WordPress versions relied solely on cryptographically signed cookies (the “auth cookies”) that stored the user’s username, a fragment of their password hash, and the session expiration.

There were no sessions, and no state on the backend.

This had many design & security disadvantages. Among others, the inability to log out a user on all their other devices.

Furthermore, a compromise of the WordPress salts, made it possible to forge authentication cookies for arbitrary users.

WordPress Version 4.0 (released on September 4, 2014) introduced significant changes to the previous cookie-based authentication scheme.

Since then, WordPress uses stateful, database-backed sessions that are stored in the user meta table.

This is done via the WP_Session_Tokens::create() method.

Removing some boilerplate code, the method looks like this:

final public function create($expiration)
{
    $session = apply_filters('attach_session_information', [], $this->user_id);
    $session['expiration'] = $expiration;
    $session['ip'] = $_SERVER['REMOTE_ADDR'];
    $session['ua'] = wp_unslash($_SERVER['HTTP_USER_AGENT']);
    $session['login'] = time();
    
    $token = wp_generate_password(43, false, false);
    
    $this->update($token, $session);
    
    return $token;
}

By default, a session contains the expiration timestamp, the current user’s IP address, the user agent, and the current timestamp (Line 3-7).

The session ID (called $token in the Core), is a random, 43 character string. (Line 9).

Both are then saved in the update method (Line 11), which ultimately ends up calling WP_User_Meta_Session_Tokens::update_sessions.

For each user, their sessions are stored in the user meta table with the “session_tokens” key.

protected function update_sessions($sessions)
{
    if ($sessions) {
        update_user_meta($this->user_id, 'session_tokens', $sessions);
    } else {
        delete_user_meta($this->user_id, 'session_tokens');
    }
}

As an aside, version 4.0 also introduced an API for developers to store their information in a user’s session.

For example:

<?php 

$session_id = wp_get_session_token();

$session_manager = WP_Session_Tokens::get_instance(get_current_user_id());

$session = $session_manager->get($session_id);

$session['foo'] = 'bar';

$session_manager->update($session_id, $session);

After the newly created session is saved, the session ID ($token) is returned to the main code path in the wp_set_auth_cookie function.

Now that a new session was created in the database, WordPress will create the actual cookie that is sent to the browser.

$auth_cookie = wp_generate_auth_cookie($user_id, $expiration, $scheme, $token);
$logged_in_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $token);

For legacy reasons, WordPress uses two different cookies, both containing the session ID. One cookie is available on the entire domain (/), the other one only in the admin area (/wp-admin).

This is technical debt from a time when not every user was connecting via HTTPS.

It allowed restricting the “admin cookie” to usage over HTTPS, while “normal” users could still access the frontend over HTTP, logged-in via the “frontend cookie”.

Nowadays, one cookie would do just fine.

The wp_generate_auth_cookie function is where it gets convoluted.

Below is a clarified version, without some boilerplate code (full version here).

function wp_generate_auth_cookie($user_id, $expiration, $scheme = 'auth', $token = '')
{
    $user = get_userdata($user_id);
    
    // CUT FOR CLARITY
    
    $pass_frag = substr($user->user_pass, 8, 4);
    
    // WE INLINED THE wp_hash() FUNCTION FOR CLARITY
    $key = hash_hmac(
        'md5', 
        $user->user_login.'|'.$pass_frag.'|'.$expiration.'|'.$token, 
        wp_salt($scheme)
    );
    
    // CUT FOR CLARITY
    
    $hash = hash_hmac(
        'sha256', 
        $user->user_login.'|'.$expiration.'|'.$token, 
        $key
    );
    
    $cookie = $user->user_login.'|'.$expiration.'|'.$token.'|'.$hash;
    
    return apply_filters('auth_cookie', $cookie, $user_id, $expiration, $scheme, $token);
}

This function works as follows:

  1. Line 3-7: The user record is fetched from the database to extract characters 8-12 from their password hash, NOT the plaintext password. This is called the password fragment ($pass_frag).
  2. Line 10-14: The username, password fragment, expiration, and the session ID ($token) are combined to create a keyed hash, using a WordPress Salt as the hash key.
  3. Line 18-22: The output of step two is used as the key for another keyed hash. This time combining only the username, expiration, and the session ID, not the password fragment.
  4. Line 24-26: The final cookie and return value is the combination of username, expiration, session ID and the hash from step three.

Effectively, the final cookie contains all input values (username, expiration, and session ID) and a cryptographic signature that ensures that nobody can tamper with any of the three values.

This code might raise two questions:

1) Why is the password hash fragment used?

This easily answered. Including the password hash fragment means that all cookie signatures will be invalid if a user changes their password, and thus, the user will be “logged out” on all their other devices.

A better way to accomplish this would be to explicitly call WP_Session_Tokens::destroy_others after a password change, but this is probably legacy code that was written before the introduction of the session API in 4.0.0

2) Why do we need two hash functions? (Step 2 and 3)

We have no idea. Maybe it’s an example of Chesterton’s Fence.

Without giving it too much thought, the same outcome could be accomplished with this much simplified function:

function wp_generate_auth_cookie_simplified($user_id, $expiration, $scheme = 'auth', $token = '')
{
    $user = get_userdata($user_id);
    
    $pass_frag = substr($user->user_pass, 8, 4);
    
    $signature = hash_hmac(
        'sha256',
        $user->user_login.'|'.$expiration.'|'.$token.'|'.$pass_frag,
        wp_salt($scheme)
    );
    
    $cookie = $user->user_login.'|'.$expiration.'|'.$token.'|'.$signature;
    
    return apply_filters('auth_cookie', $cookie, $user_id, $expiration, $scheme, $token);
}

It also creates a signature, that will be invalidated if the user’s password changes. Furthermore, the password hash fragment is not in the final cookie, same as before.

Anyhow, this newly generated cookie is now sent to the browser and can be validated on further requests.

Session validation

The counterpart to wp_set_auth_cookie is the wp_validate_auth_cookie function, which WordPress will call on every request to determine the potentially logged-in user.

Logically, wp_validate_auth_cookie should do the same work as wp_generate_auth_cookie, but in reversed order.

Below is a simplified version (full version here) of the function, without boilerplate and irrelevant code:

function wp_validate_auth_cookie( $cookie = '', $scheme = '' ) {
    $cookie_elements = wp_parse_auth_cookie( $cookie, $scheme );
    
    // CUT FOR BREVITY
    
    $scheme     = $cookie_elements['scheme'];
    $username   = $cookie_elements['username'];
    $hmac       = $cookie_elements['hmac'];
    $token      = $cookie_elements['token'];
    $expiration = $cookie_elements['expiration'];
    
    // CUT FOR BREVITY
    
    $user = get_user_by( 'login', $username );
    if ( ! $user ) {
        
        do_action( 'auth_cookie_bad_username', $cookie_elements );
        return false;
    }
    
    $pass_frag = substr( $user->user_pass, 8, 4 );
    
    // WE INLINED wp_hash for brevity.
    $key = hash_hmac(
        'md5',
        $username . '|' . $pass_frag . '|' . $expiration . '|' . $token,
        wp_salt($scheme)
    );
    
    // CUT FOR BREVITY
    
    $hash = hash_hmac( 'sha256', $username . '|' . $expiration . '|' . $token, $key );
    
    if ( ! hash_equals( $hash, $hmac ) ) {
      
        do_action( 'auth_cookie_bad_hash', $cookie_elements );
        return false;
    }
    
    $manager = WP_Session_Tokens::get_instance( $user->ID );
    if ( ! $manager->verify( $token ) ) {
      
        do_action( 'auth_cookie_bad_session_token', $cookie_elements );
        return false;
    }
    
    // CUT FOR BREVITY
    
    return $user->ID;
}

The function can be divided into the following parts:

  1. Line 2-10: The received cookie is split into the individual parts (username, expiration, etc.). If the cookie is structurally invalid/malformed, false is returned.
  2. Line 14-19: The cookie username is used to query the full user record. If no user record exists (because it was deleted) the function returns false.
  3. Line 21-30: The reversed code of the cookie signature generation part (Step 2 and 3). It is validated that the cookie was not tampered by an attacker. Without the signature validation, an attacker could change the username to the admin’s username and be logged in as an admin.

    This step is indirectly responsible for invalidating cookies when WordPress salts are changed. Since wp_salt is used as the hash key, the signature will be different and hash_equals will return false.
  4. Line 40-45: The session ID is passed to the session API for verification.
    WP_Session_Tokens::verify will query the user meta row for the user and see if there’s actually a session with the provided ID in the database.

    This step is crucial and ensures that:

    Nobody can generate valid authentication/session cookies, even if the WordPress salts are compromised.

    Session fixation is impossible, since WordPress will only accept session IDs that it generated itself.

    – Functionality such as “Log me out elsewhere” works. You can’t clear user cookies from browsers if they’re not making a request to the server, but you can delete all sessions for a user from the local database.
  5. Line 49: The cookie signature and the session are valid, and WordPress will set the user ID as the current user for the request.

Why does WordPress combine JWT-style cookies with server-side sessions?

As with many things in WordPress Core, the answer is probably backwards compatibility and technical debt.

Usually, one chooses between stateless, signature-based sessions, or server-side, stateful sessions.

Both have their advantages and disadvantages. The main advantage of using stateless, signature-based sessions is scalability, as the backend does not need to do a database lookup for every request. Instead, the validation can happen entirely in memory.

In our opinion, the overhead of one additional query (that has an index) is irrelevant in WordPress.

WordPress combines elements of both session styles, which makes the code difficult to understand and change.

Security & Threat Model

While convoluted, the session management scheme in WordPress in itself has no cryptographic flaws.

Let’s examine how WordPress’s session management stands up against various attack types that could lead to session hijacking.

No threat: Cross-site scripting (XSS)

There’s no shortage of SEO-optimized crap articles that claim that WordPress auth/session cookies can be stolen via XSS vulnerabilities in plugins.

This is not the case, as WordPress sends the session cookie with the httpOnly flag, which means that JavaScript cannot access the session cookie (unless your browser has a major zero-day vulnerability).

setcookie( $auth_cookie_name, $auth_cookie, $expire, ADMIN_COOKIE_PATH, COOKIE_DOMAIN, $secure, true );

The last argument “true” in PHP’s setcookie function means that the cookie will be sent as httpOnly

You can easily verify this yourself.

  1. Log in on any WordPress site.
  2. Type document.cookie in your browser’s developer console.
  3. You will not see any auth/session cookies.

No threat: Session fixation

Session Fixation is impossible in WordPress, we proved this in the validation part of this article. WordPress will only accept session IDs that are in the user meta table.

No threat: Brute-force / session ID Prediction

The session ID alone is made up of 43 random characters.
We’ll save you the boring math, but it’s impossible.

No threat*: Stolen WordPress salts

Another common claim is that stolen WordPress salts lead to session hijacking.

This absolutely was the case before WordPress 4.0.0.

But with the new server-side sessions, nobody can generate a session at will. Even if the authentication cookie is cryptographically valid, the contained session ID will not be present in the WordPress database and thus, the authentication cookie will be rejected.

*That said, stolen salts are obviously a disaster, and we’ll write about the consequences in a future article.

Threat: Man-in-the-middle attack

An attacker could steal your session cookies via a MITM attack if your site does not use HTTPS, or if you use untrusted public Wi-Fi (hint: SSL stripping).

Massive threat: Malware on local devices

WeWatchYourWebsite published extensive research on this attack vector inside WordPress, and it’s also a massive threat outside of WordPress, as mentioned here in “Cybercriminals crave cookies, not passwords“.

Based on a sample size of 6 million websites in 2023, they found that 60% of WordPress hacks are due to session hijacking via Malware on local devices (i.e. Computers) that are used to log in to WordPress sites.

WordPress has zero protection against this as there are no inbuilt mechanisms for session rotation, idle timeouts, timeouts for high/low privileges, etc.

The only present “protection” against a stolen session via local Malware is the 2/14 day expiration of the session cookie.

Thankfully, WordPress allows implementing custom session management functionality.

We leverage this in Fortress and rip out everything that we described in this article in favor of a more secure session management scheme.

Recap

We’ve shown that WordPress uses a combination of stateful and stateless session management.

The stateful part is backed by the WordPress database and the stateless part is a convoluted, custom message signing scheme.

WordPress’s session management protects against session theft via XSS, session fixation and brute-forcing Session IDs.

However, it provides no server-side protection against session hijacking from local devices as it misses crucial functionality such as rotation timeouts, idle timeouts, etc., which contributes to the fact that the vast majority of WordPress Hacks are caused through session hijacking.

Hopefully, this deep-dive clears up the confusion & misinformation that circulates in WordPress Security circles around the topic of WordPress Authentication Cookies & Sessions.