WordPress 6.8 shipped in April and changed how passwords are hashed, from a scheme dating to 2008 to bcrypt. Changing a password hash is one of the few migrations that cannot be run — it completes at whatever rate users log in, which for a site with annual visitors is a compatibility path that is permanent in practice.
The symptom
$ wp db query "SELECT LEFT(user_pass, 4) AS prefix, COUNT(*)
FROM wp_users GROUP BY prefix" --skip-column-names
$P$B 8104
# every hash on the site is phpass, which is a
# portable scheme designed in 2008 for hosts without
# bcrypt — 8,192 iterations of md5 by default.Eight thousand hashes, all of them under a scheme whose iteration count was chosen for the hardware of seventeen years ago. Nothing was broken; the algorithm is simply weaker than a current one and had never been questioned because it is not a thing an application chooses.
Why it happens
A password hash cannot be recomputed without the password, which is the whole point of a password hash. Any change to the algorithm therefore has to be lazy — verify with the old scheme, and rewrite on the next successful login.
The fix
What actually ships
new hashes bcrypt, prefix $2y$, cost 10
existing hashes verified with phpass, then
REHASHED on the next successful
login
application passwords a different hash entirely —
fast, because they are
high-entropy and are checked on
every API request
the old scheme still supported, indefinitely,
because it has to be
and the mechanism is wp_check_password(), which does
the verify and the rehash together.// what any integration must call
if ( wp_check_password( $plain, $user->user_pass, $user->ID ) ) {
// the third argument is what enables the rehash.
// omit it and verification still works and the
// hash is never upgraded.
}
The user ID being optional is the trap: a caller that omits it gets a correct answer and the migration silently never happens for that path. Ours had two such callers, both in code written before the ID argument existed.
The application password hash, which is deliberately different
an application password is 24 characters of generated
entropy. it is checked on every API request.
bcrypt at cost 10 ~60ms per check
4,102 API requests/hour ~4 minutes of CPU an hour,
for nothing
so application passwords use a fast hash, and that is
correct: the slow hash exists to make a weak,
human-chosen secret expensive to guess. a 24-character
random string does not need it.
using bcrypt there would be security theatre with a
measurable cost.The reasoning is worth understanding rather than accepting, because it is the one case where a fast hash is the right answer and it looks wrong in a review. The entropy is in the secret rather than in the work factor, and applying a work factor to a random 24-character string buys nothing.
The integration that compared hashes directly
// a single sign-on bridge, written in 2018
if ( $user->user_pass === md5( $incoming ) ) {
turkerdev_establish_session( $user );
}
// which had NEVER worked. WordPress has not stored a
// bare md5 since 2008, so this comparison had been
// false for seven years and a fallback branch below it
// had been doing the authentication.
A branch that has never been taken is indistinguishable from one that works, and this one was found only because the hash format change prompted a search for anything reading user_pass. The fallback had been correct throughout, which is why nobody noticed — the bug was dead code that looked like the primary path.
Measuring the migration
SELECT
CASE LEFT(user_pass, 4)
WHEN '$2y$' THEN 'bcrypt'
WHEN '$P$B' THEN 'phpass'
ELSE 'other'
END AS scheme,
COUNT(*) AS n
FROM wp_users
GROUP BY scheme;
week 1 bcrypt 412 (5%)
week 2 bcrypt 788 (10%)
week 4 bcrypt 1,208 (15%)
week 12 bcrypt 2,104 (26%)
the curve flattens after week four, because the
remaining users are the ones who log in rarely.
projected to reach 50%: about a year.
projected to reach 100%: never.A rehash-on-login migration has no completion date by construction, which means the phpass verification path is permanent rather than transitional. Planning for it to be temporary is the mistake — the compatibility code is a feature of the system now.
The login cost, measured
$ ./bin/bench-login --iterations=100
phpass verify 24ms
bcrypt verify (cost 10) 61ms
bcrypt hash (cost 10) 59ms
# a user whose hash is being upgraded pays both:
# 24ms verify + 59ms hash = 83ms, once.
# and the case that matters:
# a credential-stuffing attempt at 40 requests/second
# against 20 php-fpm workers is now 2.4 CPU-seconds
# per second of attack, against 1.0 before.The cost per login is invisible to a user and doubles the CPU of a login flood, which is the trade a slow hash makes deliberately. Rate limiting the login endpoint is what makes it survivable and had been in place since 2021 — without it, raising the work factor is a denial-of-service amplifier.
Speculative loading, in the same release
add_filter( 'wp_speculation_rules_href_exclude_paths', function ( $paths ) {
$paths[] = '/basket/remove/*';
$paths[] = '/*?action=*';
return $paths;
} );
The same release prefetches links on hover by default, which is free performance except where a GET request has a side effect. Ours was a basket removal link from 2019 — an anti-pattern that had been harmless for six years and became a bug the moment a browser started following links speculatively.
Verifying it worked
$ ./bin/hash-distribution
bcrypt 2,104 phpass 6,000
$ grep -rn 'user_pass' src/ app/ --include='*.php' | grep -v wp_check_password
# (nothing)
$ ./bin/bench-login --p95
61ms # was 24ms
$ curl -sI https://example.test/ | grep -i speculation
# and the exclude paths, asserted in a browser test
$ npx playwright test speculation-rules.spec.ts
✓ /basket/remove is not prefetched on hoverGrepping for any code reading user_pass outside the sanctioned function is the check that catches the class of bug the single sign-on bridge was, and it now runs in CI. The browser test on the speculation exclusions is the only way to assert a behaviour that lives entirely in a header.
What this costs
A verification path for a 2008 hashing scheme that will be in the codebase indefinitely, because a user who last logged in during 2019 might return. That is not WordPress’s decision to reverse either — the compatibility is the product working correctly.
The login endpoint also became more expensive at exactly the moment it became a more attractive target, since a slower hash is a better amplifier. The rate limit that makes this safe was already there and is now load-bearing in a way it was not, which is worth knowing before somebody relaxes it during a support incident.