As you might imagine, WordPress takes care of all the stuff related to user accounts for you. This system is based on user roles and capabilities.
Roles
There are 6 default WordPress roles. They are ordered in a hierarchy, where each next role has all the capabilities of the previous role and more. Here they are:
- Subscriber – can only manage their profile.
- Contributor – can write and manage their own posts but not publish them.
- Author – can manage and publish their own posts.
- Editor – can manage and publish posts of other users.
- Administrator – has access to all administration features.
- Super Administrator – only relevant in WordPress Multisite. We’ll cover it when discussing this topic.
You can add new roles using add_role(). You can remove roles using remove_role(). Your roles don’t have to fit in this hierarchy. You can have a role which has some capabilities of an administrator, while simultaneously not being able to publish posts. Every user on your website has to have a role. A single user can have multiple roles.
Capabilities
Capabilities are the cornerstone of WordPress’s user system. To understand how they work, you have to understand why they exist in the first place.
Let’s say you want to display a banner at the top of your website only for users with certain roles. A naive approach would be to check the current user’s role and compare them to some list of roles. This would work, but what happens when you add a new role and want to display that banner for it as well? You have to go into the code and modify the set of roles.
Capabilities solve this by decoupling the question “can this user do it?” from the question “what role does this user have?”. In our banner case, instead of checking for a role, we would check the capabilities of the current user. Here is a code snippet:
if ( current_user_can( 'pgn_view_banner' ) ) {
pgn_display_banner();
}Every role has a set of capabilities assigned to it. If we now wanted to add a new role and make it see the banner, we would only have to add the “pgn_view_banner” capability while creating the role. The role is completely detached from the capability itself.
There are currently over 60 default capabilities in WordPress. Some of the most important ones are:
- edit_posts
- delete_posts
- publish_posts
- upload_files
- edit_other_posts
- install_plugins
- update_plugins
This is a short list just to give you a concept of what kinds of capabilities there are and how they are used by WordPress. The core is full of these capability checks. To see the full list of all capabilities along with what default roles have them, see the official Roles and Capabilities Documentation.
Of course, there is no such default capability as “pgn_view_banner”. We have to add it ourselves. It’s just a string stored in the array of all capabilities associated with a given role. You’ll see how that works under the hood soon.
PS: Capabilities can also be assigned directly to individual users using WP_User::add_cap().
Users In The Database
The user system utilizes 3 tables in the database: wp_users, wp_usermeta, and wp_options. Let’s start with the first two.
wp_users contains all of the most important information about the user account. The most crucial columns are:
- ID
- user_login – plaintext login.
- user_pass – hashed password.
- user_email – plaintext email.
- user_registered – time and date the account was created on.
- display_name – public facing nickname.
There are obviously more things you want to store for your users, which is why wp_usermeta exists. It works exactly like wp_postmeta we covered when discussing Custom Fields. It even provides the same CRUD functions: add_user_meta(), update_user_meta(), get_user_meta(), and delete_user_meta(). It’s used to store things like first and last name or description, but you can use it to store any arbitrary key-value pair associated with a user.
WordPress uses wp_options to store information about registered roles and their capabilities. Remember how registering a custom post type or a taxonomy was done only in code and not stored in the database? That’s not the case for roles.
In practice, this means that functions like add_role() or WP_Role::add_cap() modify the database. They might have different behavior depending on the current state of roles/capabilities saved in the database, but the rule of thumb is to run them only once on hooks such as plugin activation. This ensures you’re not making any unnecessary database calls on every page load.
The actual role info is stored in an option named wp_user_roles. The value of this option is a serialized associative array of roles and their capabilities. It looks something like this:
array(
'administrator' => array( 'switch_themes', 'edit_themes', 'activate_plugins', ...),
'editor' => array( 'edit_posts', 'publish_posts', 'upload_files', ...),
// other roles....
);We skipped over a crucial part – how roles are associated with users. This information is stored in wp_usermeta with the wp_capabilities key. Here is the value of this entry for a typical administrator user:
a:1:{s:13:"administrator";b:1;}It’s just a serialized array that would look like this: array( ‘administrator’ => true ). But let’s now do something interesting. Do you remember how I mentioned that you can assign capabilities directly to individual users? Let’s create a new subscriber user and then run this code in functions.php:
function thm_add_user_cap() {
$user = get_user_by( 'id', '3' ); // hard-coded ID for simplicity
$user->add_cap( 'contributor' );
}
add_action( 'init', 'thm_add_user_cap' );That’s what the wp_capabilities value now looks like for this user:
a:2:{s:10:"subscriber";b:1;s:11:"contributor";b:1;}What the hell? We added a capability and it looks and is stored exactly like a role! Let’s write some debugging code to see how it works. We’ll check if this user can edit posts, which is a capability assigned to the contributor role but not to the subscriber role. We will also check if the user has the capability “contributor” that we just gave them.
function thm_test_user_cap() {
$can_edit = user_can( 3, 'edit_posts' );
if ( $can_edit ) {
echo 'can edit ';
} else {
echo 'cant edit ';
}
$can_contributor = user_can( 3, 'contributor' );
if ( $can_contributor ) {
echo 'can contributor';
} else {
echo 'cant contributor';
}
}
add_action( 'init', 'thm_test_user_cap' );Guess what gets echoed. “can edit can contributor”. That’s right. WordPress does not differentiate between a role and a capability. Both of them are just a string associated with the wp_capabilities meta key. Instead, if a capability happens to also be a role, all the other capabilities assigned to it (stored in wp_options) also get assigned to the user. A role is basically just an alias for a set of capabilities.
This means that you can check if “a user can administrator”, which makes no semantic sense, and the return value will tell you if the user has the role administrator. That being said – treat this as a curiosity and not a useful tool. It’s a non-standard way of using this function and is explicitly discouraged by the official documentation.
Sessions
WordPress does not use PHP sessions ($_SESSION) for logins. It uses signed cookies along with a server-side session token. This topic is a little advanced and requires some cryptography knowledge.
When you log into WordPress, it places two cookies on your browser – wordpress_logged_in_{COOKIEHASH} and wordpress_{COOKIEHASH} (or wordpress_sec_{COOKIEHASH}). Both of these cookies are a string with pipe-delimited pieces of data. The structure of the cookie is as follows:
username | expiration | token | HMAC
- username is the plaintext username of the account you’re logged into.
- expiration is the UNIX timestamp of when the session is set to expire. The default session length is 48 hours or 14 days if the user clicks “Remember me”.
- token is a random string.
- HMAC (Hash-based Message Authentication Code) is a cryptographically secure hash making this entire system secure.
An example cookie value might look like this:
admin|1756828649|b5mqL93RvtNKGfrSMOGnQibxNlhcVR4Ebtfvd5P0Oxk|fa881a1845f07b6fb244f1f7f10e844ab70ce061306a1b28gb40b13fcaae92dbLet’s untangle this step by step. First, what is COOKIEHASH in the name of the cookie? It’s the md5 hash of your website’s siteurl. If your siteurl, which is an option set in Settings and stored in wp_options, was https://example.com, the COOKIEHASH would be equal to “c984d06aafbecf6bc55569f964148ea3”. This is used to ensure that sessions for different WordPress websites hosted under the same domain don’t disrupt each other.
The token is basically just a random 43-character string generated for every session. The cookie you can see above contains its plaintext version. Its SHA-256 hashed version is also stored in the database in the wp_usermeta table.
The meta key is “session_tokens”. The data is a serialized associative array of tokens (one per device), containing:
- the hashed token,
- expiration UNIX timestamp,
- user IP,
- user agent,
- login UNIX timestamp.
The HMAC is where the magic happens. Its computation is a little intense, but I’ll do what I can to make it understandable. First, a string is created by concatenating the username, a fragment of the user’s password hash, the expiration timestamp, and the token. The password fragment is just 4 characters of the password. Here’s the string:
$username . '|' . $pass_frag . '|' . $expiration . '|' . $tokenThis string is passed to wp_hash() along with a scheme. A scheme is just a string with one of four values – “auth”, “secure_auth”, “logged_in”, or “nonce”. This is a good moment to answer a question that might’ve been bugging you for a while. Why does WordPress place two different session cookies and what’s the deal with their names?
It’s done to separate the /wp-admin cookie from the frontend cookie. The wordpress_logged_in_{COOKIEHASH} is placed on the root URL of your website. It is used to validate your session for all requests on the frontend. The wordpress_{COOKIEHASH} cookie (or the wordpress_sec_{COOKIEHASH} cookie if the website uses HTTPS) is placed on the /wp-admin path only.
The /wp-admin path has a different authentication cookie. What this means is that no frontend code (like JavaScript or requests to frontend URLs) ever sees the cookie that grants access to the admin panel. This is good. A vulnerability in any frontend code (e.g., a XSS attack) exposing the cookies would not allow the attacker to log into the /wp-admin panel, only to the frontend of the website. By “frontend” I mean any URL that’s not /wp-admin. By the way, every user gets an auth cookie, as even subscribers can update their profile in /wp-admin/profile.php
These cookies have the same contents, except for the HMAC. The reason they have a different HMAC is that they use different salts. Salts for each type of a cookie are defined as constants in the wp-config.php file. Example (shortened for readability):
define('AUTH_KEY', ' Xakm<o xQy rw4EMsLKM-?!T+,PFF})H4lzcW57AF0U');
define('SECURE_AUTH_KEY', 'LzJ}op]mr|6+![P}Ak:uNdJCJZd>(Hx.-Mh#Tz)p');
define('LOGGED_IN_KEY', '|i|Ux`9<p-h$aFf(qnT:sDO:D1P^wZ$$/Ra@miTJi');
define('NONCE_KEY', '%:R{[P|,s.KuMltH5}cI;/k<Gx~j!f0I)m_sIyu+&NJZ)-i');
define('AUTH_SALT', 'eZyT)-Naw]F8CwA*VaW#q*|.)g@o}||wf~@C-YSt}(d');
define('SECURE_AUTH_SALT', '!=oLUTXh,QW=H `}`L|9/^4-3 STz},T(w}W<I`.Jj');
define('LOGGED_IN_SALT', '+XSqHc;@Q*K_b|Z?NC[3H!!EONbh.n<+=uKR:>');
define('NONCE_SALT', 'h`GXHhD>SLWVfg1(1(N{;.V!MoE(SfbA_ksP@&`+A');Every scheme (auth, secure_auth, logged_in, and nonce) has an associated {scheme}_KEY and {scheme}_SALT constant. The final salt is really generated by concatenating these two constants, so you can think about them as halves of the final salt. I’m not entirely sure why there need to be two constants but let’s roll with it.
This is where we go back to our wp_hash() call with the string containing $username, $pass_frag, $expiration, and $token. I mentioned that the scheme is passed to this function. It is then used to get the correct salt. Again – this salt is going to be different for logged_in and for auth, which is what makes the frontend cookie different from the admin cookie.
The wp_hash() function computes and returns an md5 hash, with the data being the concatenated string, and the salt being the salt defined in wp-config.php. This hash becomes a key used to compute the final HMAC. Let me reiterate that. The hash generated with wp_hash() is not the HMAC. It is the key used to generate the HMAC.
The HMAC is computed very similarly, except it uses SHA-256 instead of md5. The hashed data is another concatenated string, this time without the password fragment:
$username . '|' . $expiration . '|' . $tokenAgain, the secret key used to generate this HMAC is the hash returned by wp_hash(). Notice that it is the only secret in this equation. If you knew the output of wp_hash(), you could compute the HMAC for any set of username|expiration|token parameters.
So what’s making this hash secure? The 4 characters of the password add some marginal security, but their main purpose is to invalidate all sessions when the user changes their password. What is really meant to be the secret are the salts. That’s right – the constants defined in wp-config.php.
This might come as a surprise if you know anything about cryptography, as salts aren’t usually meant to be secret. In reality, WordPress calls them “salts”, but they are really secret keys. It’s not the only confusing part of WordPress’s terminology, which you’ll soon find when we discuss nonces.
In practice, this has a few significant implications. First of all, your wp-config.php file is very important. If someone gains access to those secret keys, they can have a good shot at forging the HMAC of a session. They would then, for example, be able to compute the /wp-admin cookie using the data from the frontend cookie. Not to mention that this file stores your database credentials. Make sure to keep it secure.
The Authentication Lifecycle
Let’s summarize the way sessions work in WordPress by looking at every step in order.
When you log in:
- WordPress checks the username and compares the password hash with the hash stored in the database (in wp_users).
- WordPress generates a random token and stores its SHA-256 hash in the wp_usermeta along with its expiration, IP, UA, and login timestamp.
- Your username, expiration, token, and 4 characters of your password are used to compute a hash key.
- The salts stored in wp-config.php are also used to compute that hash key. They provide security. They are different for the admin cookie and the frontend cookie.
- The hash is used, along with your username, expiration, and token, to compute the final HMAC (again, different for admin and frontend).
- The cookies are returned to the browser in a Set-Cookie header. The frontend cookie is placed on the root of the domain (/), and the admin cookie is placed only on /wp-admin.
When you visit a page logged in:
- WordPress parses the cookie and checks if the expiration timestamp is in the past (the cookie has expired). If so, it gets invalidated.
- WordPress independently computes the HMAC with the username, token, and expiration from the cookie, and the 4 character password fragment read from the database. The same salts from wp-config.php are used.
- If the HMAC doesn’t match, the authentication is unsuccessful.
- WordPress checks if the token’s SHA-256 hash is present in wp_usermeta. If not, authentication is unsuccessful.
- WordPress sets the global $current_user object.