2025, Nov 07 15:00
Flask authentication against WordPress 6.8+: verify $wp bcrypt 2y hashes with HMAC-SHA384, keep $P$ phpass support
WordPress 6.8 uses $wp + bcrypt 2y with HMAC-SHA384, breaking Flask auth. Learn to verify new hashes and keep legacy $P$ phpass logins across mixed databases.
Flask authentication against WordPress 6.8+ can suddenly break after an upgrade. The reason is simple: WordPress changed how it stores password hashes. Where older installs produced $P$-prefixed hashes (phpass), WordPress 6.8+ writes hashes that start with $wp and embed a bcrypt 2y hash. If your Python code directly verified the legacy format, it will reject the new one until you adapt the verifier.
Problem setup
Here is a minimal example of the old approach that worked with $P$ (phpass) hashes. The logic is straightforward: load the stored hash, verify the plaintext password, and return a result. The variable names are different here, but the behavior mirrors the original.
from passlib.hash import phpass
class Gatekeeper:
def __init__(self, app):
self.app = app
def check_login(self, user_email, plain_secret):
stored_hash = self.fetch_hash(user_email)
# stored_hash looked like: $P$...
# plain_secret was plaintext, e.g. "something"
if not phpass.verify(plain_secret, stored_hash):
return False, Reply.invalid_auth(self.app.site_url)
return True, None
After the update, trying to switch to a generic bcrypt checker like flask_bcrypt leads to errors such as “Invalid salt” when pointed at $wp2y$10$... values. The new WordPress hashes are not plain bcrypt strings; they have a specific prefix and a pre-hash step that must be replicated when verifying.
What changed in WordPress 6.8+
WordPress 6.8.1’s password routine adds a pre-hash before bcrypt. A HMAC-SHA384 is computed over the trimmed password using a fixed key "wp-sha384". WordPress then runs bcrypt with ident 2y, and prefixes the final output with $wp to distinguish it from vanilla bcrypt. That prefix is the visible indicator that a password came from the new scheme.
Fix: verify with the WordPress 6.8+ flow
To authenticate from Flask, reproduce the WordPress 6.8+ pipeline on the Python side. First perform the HMAC-SHA384 over the candidate password with the key "wp-sha384", then verify that pre-hash against the bcrypt part of the stored string after removing the $wp prefix. This matches the WordPress logic and lifts bcrypt’s 72-byte input limit without breaking compatibility with their format.
import hmac
import hashlib
from typing import Union
from passlib.hash import bcrypt
def wp_pre_digest(secret: bytes) -> bytes:
keyed = hmac.new('wp-sha384'.encode('utf-8'), digestmod=hashlib.sha384)
keyed.update(secret)
return keyed.digest()
def wp_hash_build(secret: Union[bytes, str]) -> str:
if isinstance(secret, str):
secret = secret.encode('utf-8')
pre = wp_pre_digest(secret)
baked = bcrypt.using(rounds=10, ident='2y').hash(pre)
return '$wp' + baked
def wp_check_secret(secret: Union[bytes, str], combined_hash: bytes) -> bool:
if combined_hash[:3] != '$wp':
raise ValueError('not WordPress >= 6.8 password hash')
peeled = combined_hash[3:]
if isinstance(secret, str):
secret = secret.encode('utf-8')
pre = wp_pre_digest(secret)
return bcrypt.verify(pre, peeled)
If your database mixes old and new hashes, branch by prefix. Keep the existing phpass verifier for $P$, and use the new flow for $wp. This way you can authenticate users across both generations without forcing a migration step.
from passlib.hash import phpass
class Gatekeeper:
def __init__(self, app):
self.app = app
def check_login(self, user_email, plain_secret):
stored_hash = self.fetch_hash(user_email)
try:
if stored_hash.startswith('$wp'):
ok = wp_check_secret(plain_secret, stored_hash)
else:
ok = phpass.verify(plain_secret, stored_hash)
except Exception:
ok = False
if not ok:
return False, Reply.invalid_auth(self.app.site_url)
return True, None
Why this matters
Password verification is not just about picking a hashing function; it is about matching the exact format and steps used by the system that produced the hash. WordPress 6.8+ deliberately added a pre-hash and a custom $wp prefix to extend password length handling and make the resulting hashes clearly distinguishable. Ignoring these details leads to spurious “Invalid salt” errors or, worse, acceptance failures that look like bad credentials.
Takeaways
When your Flask service needs to authenticate against WordPress 6.8+, inspect the stored value and choose the correct verifier. For legacy $P$ hashes, keep using phpass verification. For the new $wp...2y$10$ format, pre-hash the candidate with HMAC-SHA384 keyed as "wp-sha384" and verify it with bcrypt 2y. This alignment with WordPress internals restores login flows without changing your data model and keeps mixed-era user tables working smoothly during upgrades.
The article is based on a question from StackOverflow by Pythonic007 and an answer by President James K. Polk.