TechEarl

How WordPress Stores Passwords (and the 2025 Move to bcrypt)

WordPress stores a one-way password hash in wp_users.user_pass, a VARCHAR(255) column. Historically a phpass $P$ portable hash (MD5-based but stretched), and bcrypt for new hashes since WordPress 6.8 in 2025.

Ishan Karunaratne⏱️ 12 min readUpdated
Share thisCopied
How WordPress stores passwords: a one-way hash in the wp_users.user_pass VARCHAR(255) column, historically a phpass $P$ portable hash and bcrypt for new hashes since WordPress 6.8.

WordPress never stores your password. It stores a one-way hash of it, in a single column: user_pass on the wp_users table, which is a VARCHAR(255). Feed a login attempt back through the same hashing function, compare the result against what is stored, and that is how it checks you in without ever knowing the plaintext.

Short answer: the hash lives in wp_users.user_pass, a VARCHAR(255) column. From WordPress 2.5 (2008) through 6.7, that hash was a phpass portable hash with a $P$ prefix, an MD5-based but deliberately iterated hash, not raw MD5. Since WordPress 6.8 (April 2025), new hashes are bcrypt (with a $wp$2y$ prefix and a SHA-384 pre-hash step), and old $P$ hashes are transparently upgraded to bcrypt the next time the user logs in. The column stayed VARCHAR(255) the whole way through, which is exactly why that width is the right default for any password column you build yourself.

I ran a WordPress agency for years, and the number of times I had to explain to a client that "we genuinely cannot tell you your old password, only reset it" is too high to count. This is why.

Where WordPress keeps the password

There is no separate credentials store, no encrypted vault, no second table. The hash sits in the same wp_users row as your login name and email:

sql
SELECT ID, user_login, user_pass FROM wp_users WHERE user_login = 'admin';
-- ID  user_login  user_pass
-- 1   admin       $P$BVbR9X8a4Q1f0sH4nLpQpC3mDxkqU./

The schema declares user_pass as a VARCHAR(255):

sql
CREATE TABLE wp_users (
  ID                  BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
  user_login          VARCHAR(60)  NOT NULL DEFAULT '',
  user_pass           VARCHAR(255) NOT NULL DEFAULT '',
  user_nicename       VARCHAR(50)  NOT NULL DEFAULT '',
  user_email          VARCHAR(100) NOT NULL DEFAULT '',
  -- ... user_url, user_registered, user_activation_key, etc.
  PRIMARY KEY (ID),
  KEY user_login_key (user_login),
  KEY user_nicename (user_nicename),
  KEY user_email (user_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Two things matter here. First, the value is never the plaintext and never reversible. A hash function only goes one way, so a stolen wp_users table does not hand the attacker your password. It hands them a value they have to brute-force, which is the whole point of using a slow hash. Second, the column is VARCHAR(255), comfortably wider than any hash WordPress has ever put in it. A phpass portable hash is 34 characters; a WordPress bcrypt hash is around 60-65. The 255 leaves enormous headroom, which is deliberate, and I will come back to why it is the right call for your own tables.

The phpass era: $P$ portable hashes

From WordPress 2.5 in 2008 until 6.7, WordPress hashed passwords with phpass, the Portable PHP Password Hashing framework written by Solar Designer (Alexander Peslyak). The implementation lives in wp-includes/class-phpass.php as the PasswordHash class, and WordPress configures it to use phpass's "portable hash" mode.

A portable hash looks like this:

code
$P$BVbR9X8a4Q1f0sH4nLpQpC3mDxkqU./

Breaking that apart:

  • $P$ is the identifier for a phpass portable hash. (WordPress core's variant sometimes emits $H$, the same format under a different tag.)
  • The next character (B above) encodes the iteration count as a power of two. WordPress instantiates the hasher with iteration_count_log2 of 8, so the hash is computed with 2^8 = 256 passes of MD5.
  • The following 8 characters are the per-password random salt.
  • The remainder is the base64-style encoded digest.

The single most common misconception, and one I corrected on the IRC #wordpress channel more times than I can remember, is "WordPress passwords are just MD5". They are not. The portable hash is MD5-based: the underlying primitive is MD5, but it is salted and stretched. The salt (unique per password) defeats rainbow tables, and the 256-pass loop makes each guess hundreds of times more expensive than a single bare MD5. That is night-and-day against a plain MD5(password), which a GPU chews through at billions of guesses per second.

It is still not great by 2025 standards. MD5 is fast even iterated a few hundred times, and modern cracking rigs make portable hashes meaningfully weaker than a proper slow hash. That is exactly why the project moved on. But "WordPress stores your password as MD5" was always wrong, and worth correcting, because it leads people to assume a leaked wp_users table is trivially crackable. It is not as trivial as raw MD5, though for why raw MD5 is the wrong choice for passwords, the reasoning is the same family of problem.

The public API, all defined in wp-includes/pluggable.php so plugins can override them, is:

  • wp_hash_password($password) produces the stored hash.
  • wp_check_password($password, $hash, $user_id) verifies a login attempt against the stored hash.
  • wp_set_password($password, $user_id) hashes and writes the new value into user_pass directly.

Because they are pluggable, security plugins (and Roots' old wp-password-bcrypt drop-in) could swap the algorithm out long before core did.

The 2025 switch to bcrypt (Updated)

Updated for WordPress 6.8, April 2025. WordPress 6.8 changed the default algorithm for new password hashes from the phpass portable hash to bcrypt, closing a Trac ticket that had been open since 2012. I verified the specifics below against the official Make WordPress Core announcement.

Here is what actually changed for the user password in user_pass:

  • wp_hash_password() and wp_check_password() now use PHP's native password_hash() and password_verify() with the bcrypt algorithm. No more phpass for new hashes.
  • Passwords are pre-hashed with a keyed SHA-384 (HMAC) before bcrypt. bcrypt silently truncates its input at 72 bytes, so a long passphrase would lose everything past byte 72. WordPress runs the password through HMAC-SHA-384 (keyed with the literal string wp-sha384 for domain separation) first, collapsing any length into a fixed-size digest so the full password contributes to the result. (The digest is base64-encoded before being handed to bcrypt so it is safe to pass as a string.)
  • New hashes carry a $wp$ prefix, so the full stored value starts with $wp$2y$. The $wp marker disambiguates a WordPress SHA-384-pre-hashed bcrypt hash from a plain $2y$ bcrypt hash that a plugin might have written. It is not used for any algorithm other than bcrypt.

A stored 6.8 hash looks roughly like:

code
$wp$2y$10$Q9...     (bcrypt, cost 10, SHA-384 pre-hashed, WordPress-tagged)

The cost factor (10 above) is PHP's password_hash() default, not a hardcoded WordPress value: it is 10 on PHP up through 8.3 and 12 on PHP 8.4. WordPress deliberately leaves it to the PHP default so the work factor rises for free as PHP raises it, with no WordPress release needed. The cost is encoded in the hash, so an old cost-10 hash keeps verifying after PHP bumps the default.

Crucially, the upgrade is transparent and lazy. Old $P$ portable hashes are not invalidated by the update. wp_check_password() still recognises and verifies them, and when a user with a legacy hash logs in successfully (or resets their password), WordPress rehashes the now-known plaintext with bcrypt and rewrites user_pass in place. So a site that updates to 6.8 migrates its users to bcrypt gradually, one login at a time, with zero forced resets.

One thing to keep separate: WordPress 6.8 also moved application passwords, password-reset keys, personal-data-request keys, and the recovery-mode key off phpass, but those use a fast BLAKE2b hash via Sodium (wp_fast_hash() / wp_verify_fast_hash(), with a $generic$ prefix), not bcrypt. That is correct: those values are already long and random, so they do not need a slow hash. The user login password is the one that gets bcrypt. Do not confuse either of these with the AUTH_KEY / SECURE_AUTH_KEY salts in wp-config.php either. Those are cookie-signing secrets, not password hashes, and they never touch user_pass.

Checking and setting a password the WordPress way

If you are writing code against WordPress, never touch user_pass with raw SQL. Use the API and let it pick the current algorithm:

php
// Verify a login attempt
if ( wp_check_password( $entered, $user->user_pass, $user->ID ) ) {
    // correct; wp_check_password also triggers the bcrypt
    // upgrade for legacy $P$ hashes on success
}

// Set a new password (hashes + writes user_pass for you)
wp_set_password( $new_plaintext, $user->ID );

wp_set_password() runs the plaintext through wp_hash_password() and updates the row, so on 6.8+ you get a bcrypt $wp$2y$ hash automatically, and you never assemble the hash yourself.

The SQL gotcha. There is an old trick, ten-plus years of forum answers deep, of resetting a locked-out admin straight in the database with UPDATE wp_users SET user_pass = MD5('newpass') WHERE .... This historically worked because WordPress's login path detected a bare 32-character MD5 in user_pass, accepted it once, and immediately upgraded it to a proper salted hash on that first successful login. It was a deliberate backward-compatibility path from the pre-2.5 days when WordPress really did store plain MD5.

I would not lean on it now. It is a legacy code path, it is exactly the foothold an attacker with write access to your database wants, and it is the kind of thing that quietly gets tightened in a future release. The clean way to reset a locked-out user is wp_set_password() (via WP-CLI: wp user update <id> --user_pass='...'), which writes a current-algorithm hash directly. If you must go through SQL, generate a real bcrypt hash in PHP with wp_hash_password() and paste that into user_pass, rather than dropping a raw MD5 and relying on the upgrade path.

What this means if you store passwords yourself

The WordPress story is a clean lesson for anyone designing their own auth table, and it comes down to two decisions.

Use a slow, purpose-built password hash at the application layer. bcrypt, argon2id, or scrypt. Never a bare MD5() or SHA2() in the database. WordPress spent over a decade on an iterated-MD5 hash precisely because a fast hash is a liability for passwords: speed helps the attacker brute-forcing a stolen table, not you. The move to bcrypt in 6.8 is the project finally landing where new code should have started.

Store the result in a VARCHAR(255). This is the part people get wrong by under-sizing. A bcrypt hash is 60 characters, argon2id output runs longer, and the encoded string already carries the algorithm, cost factor, and salt inside it, so you store the one value and nothing else. VARCHAR(255) is the right width for a password column because it covers every modern scheme with room to spare and costs nothing for shorter values (a VARCHAR only uses the bytes it needs plus a length prefix). WordPress kept user_pass at VARCHAR(255) across both the phpass and bcrypt eras without a schema change, which is the whole argument: size the column for the format, not for today's algorithm. If you want the dedicated walkthrough, see how to store a bcrypt hash in MySQL and the wider question of which column type a password belongs in, backed by the byte sizes in the MySQL data types reference.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressMySQLPassword HashingbcryptphpassDatabase StorageSecurity

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Store an Argon2 Password Hash in MySQL

Store an Argon2id password hash in MySQL or MariaDB the right way: VARCHAR(255), never a fixed-width column. The encoded format, why its length varies, computing it in PHP / Python / Node, OWASP parameters, and a worked users schema.

How to Store a bcrypt Password Hash in PostgreSQL

A bcrypt hash is a fixed 60-character string. In PostgreSQL the right column is text (varchar(255) is equivalent). Why text over char(60), app-side hashing, the pgcrypto crypt() option, and a worked users schema.