The admin email points at a mailbox you closed two years ago. Or the host quietly stopped relaying wp_mail(). Or the reset link is sitting in a spam folder nobody owns. The “Lost your password?” form cheerfully says a message has been sent, and nothing ever lands. The site is yours, you pay the hosting invoice, and you cannot get past the login form.
The way back in runs through one column of one table: user_pass in wp_users. What sits in that column is not your password. On a current install it is a bcrypt hash carrying a three character prefix. On a site that has not had a password changed since 2015 it is a phpass hash starting $P$. You cannot type a plain password into that field and expect it to work, and you cannot read the old one back out.
Everything below was checked against wp-includes/pluggable.php on a WordPress 7.0.4 install running PHP 8.5.9. The branch order, the condition that lets an old MD5 through, the wp-sha384 key string and the bcrypt default cost were read out of that file and, where it mattered, run through the PHP binary to confirm.
One triage step first. If /wp-admin/ gives you a blank white page or a database error instead of a login form, your password is not the problem and no hash will fix it. Those are a fatal PHP error and a connection failure respectively, and they need different work. This article assumes the login form loads, rejects you, and refuses to send mail. It also assumes you own the site and can already reach its database, with the credentials sitting in wp-config.php.
The tool below builds the value you need to put in that column, and it does the general hashing and encoding work too. It runs entirely in your browser. Nothing you type or drop into it is uploaded anywhere.
Hashes and encoding
A workbench for hashes and encodings, and a way back into a WordPress site whose admin password is lost. Every digest, every conversion and every password hash is worked out here in this browser tab with crypto.subtle and code that ships with the tool. A dropped file is read from your own disk and never uploaded, there is no request to a server and no logging.
1Input
Type or paste below, or drop a file. Everything under here updates as you go.
or press Enter to choose one. It is read in the browser, never uploaded.
2Hashes
MD5 and CRC32 are computed by the tool, the SHA family through crypto.subtle. Hex is lower case unless you tick the box above.
Type a key to see the four HMAC values.
3Encoding
The same input, converted both ways. Decode reads the field above as already encoded and turns it back into text.
4WordPress password
Locked out of your own site? Turn a plain password into a value you can paste straight into the user_pass column, in two forms.
wp_check_password compares the plain password against md5() whenever the stored value is 32 characters or shorter, so this logs you straight in. WordPress then replaces it with a modern hash the first time you sign in.
The current WordPress format: the password is run through HMAC-SHA384 with the key wp-sha384, base64 encoded, then bcrypt hashed, with $wp in front so WordPress can tell it apart from a plain bcrypt hash. The salt is fresh from crypto.getRandomValues each time, so the value changes on every keystroke.
WP-CLI hashes the password for you, so it takes the plain text, not either value above.
One rule. This is for regaining access to a site you own. Setting someone else's password on a server you do not control is not covered by anything here.
The condition that lets a bare MD5 in
The advice you find everywhere is “paste an MD5 into user_pass and log in”. That advice is twenty years old and it is still correct, but almost nobody quotes the reason. Here is the actual opening of wp_check_password(), verbatim from wp-includes/pluggable.php:
if ( strlen( $hash ) <= 32 ) {
// Check the hash using md5 regardless of the current hashing mechanism.
$check = hash_equals( $hash, md5( $password ) );
} elseif ( ! empty( $wp_hasher ) ) {
...
Read what that test is. It is not a format check, not a prefix check, not a regular expression. It is a length check. If the stored string is 32 characters or shorter, core compares it to md5( $password ) with hash_equals() and nothing else is consulted. An MD5 digest in hex is exactly 32 characters, so it slides under the limit.
Two consequences fall out of that ordering. First, this branch runs before the $wp_hasher check, so a security plugin that swaps in its own hasher does not close this door. Second, it runs before the 4096 character password length guard further down. The MD5 path is the very first thing core tries.
An empty user_pass is also 32 characters or shorter, but it matches nothing: md5() always returns exactly 32 characters and hash_equals() compares the full strings. A blank column is unusable, not open.
What the next login does to it
The MD5 you paste in does not stay. In wp-includes/user.php, immediately after wp_authenticate_username_password() confirms the password is valid, this runs:
if ( wp_password_needs_rehash( $user->user_pass, $user->ID ) ) {
wp_set_password( $password, $user->ID );
}
wp_password_needs_rehash() returns true for anything that is not already prefixed with $wp while bcrypt is the active algorithm, which covers your MD5. So core takes the plaintext it just verified, hashes it properly, and writes the modern value back over your temporary one. You log in once with the weak hash and the column repairs itself. The same line sits in the email login path a little further down the file.
That is the whole trick, and it is why the MD5 route is safe enough for a recovery you perform in a couple of minutes. It is not safe as a resting state. A 32 character MD5 sitting in a live database is a password an attacker with a copy of that table cracks in seconds, so do the login straight away rather than leaving it there overnight.
There is a matching detail on the reset key. wp_signon() clears user_activation_key after a successful login, so a stale reset key left over from the mails that never arrived cleans itself up at the same moment.
The modern format, and the 72 byte wall
If you would rather write the real thing straight into the column and skip the MD5 stage, you need to reproduce what wp_hash_password() does. It is two operations, not one, and the first one exists for a reason worth knowing.
bcrypt ignores everything past the first 72 bytes of its input. That is a property of the Blowfish key schedule, not a WordPress decision, and it means a 90 character passphrase and the same passphrase with a different ending produce identical hashes. Rather than accept that, core compresses the password into a fixed size digest first, so every byte of a long passphrase influences the result.
The compression is an HMAC, not a plain hash, using SHA-384 with the literal key string wp-sha384. The comment in core calls this domain separation: the same password run through the same construction elsewhere in the codebase, or in some other application, will not produce this value, because the key differs. The arithmetic then works out neatly. SHA-384 produces 48 bytes, 48 divides evenly by 3, and base64 turns every 3 bytes into 4 characters, so the encoded digest is exactly 64 characters with no padding. Sixty-four fits under seventy-two with room to spare.
That 64 character string is what actually gets bcrypted. The result is then given a $wp prefix before it is stored, which is purely a marker: it tells wp_check_password() that the stored digest was produced from a pre-hashed password rather than from the raw one, so the verify side knows to apply the same HMAC before calling password_verify() on substr( $hash, 3 ). A stored value therefore reads $wp$2y$12$ and so on, 63 characters in total: three for the prefix and sixty for the bcrypt hash.
The WordPress section of the tool produces both forms from a plain password: the bare md5() for the fast route, and the full $wp value with a bcrypt cost you choose from 10, 11, 12 or 13. Each comes with a matching UPDATE statement built from the table prefix and user login you give it, and a WP-CLI equivalent for when you have shell access instead of phpMyAdmin. The MD5 output is always lowercase there, whatever the uppercase hex checkbox is set to, because md5() in PHP returns lowercase and hash_equals() is byte for byte.
Why $P$ hashes from 2011 still open the door
Between 2008 and WordPress 6.8, passwords were hashed with phpass, Openwall’s portable scheme. Those hashes start $P$, run 34 characters, and are stretched MD5: an 8 character salt, then a loop of raw MD5 calls over the running digest plus the password. The character at index 3 encodes the iteration count as a power of two, and WordPress used a setting that works out to 8192 rounds.
Core still honours them. wp_check_password() has an explicit $P$ branch that loads class-phpass.php and verifies through it, which is why a site restored from a decade old backup still lets its owner in. And because a $P$ hash is not $wp prefixed, the rehash line fires on that first successful login and quietly upgrades the row to bcrypt. Most sites migrated without anyone noticing, one user at a time, as people logged in.
One import trap: only $P$ is routed to phpass. The related $H$ identifier, which the phpass class itself accepts, falls through to the final password_verify() branch and fails there.
The cases that bite
Four things go wrong often enough to name.
- Leading and trailing whitespace.
wp_hash_password()callstrim( $password )before hashing. The$wpbranch ofwp_check_password()does not trim. A password that begins or ends with a space is stored as the trimmed version and then compared untrimmed, so it will not match. Trim your input before you generate anything. - The bcrypt cost. WordPress passes no cost option, so it inherits PHP’s default. On this server PHP 8.3 reports a default cost of 10 and PHP 8.5 reports 12. Generate a cost 12 hash for a site running PHP 8.3 and
password_needs_rehash()returns true, so the row is silently rewritten at the next login. Nothing breaks, but the value you carefully pasted will not be the one that stays. - Object caching.
wp_set_password()follows its database write withclean_user_cache(). A raw SQLUPDATEdoes not. On a site with a persistent object cache in Redis or Memcached, the old user object can survive your change and keep rejecting the new password. Flush the cache after the update, or use the WP-CLI form instead, which goes through the proper function. - Corrupted paste. A hash that travelled through a chat window or a word processor can arrive with a smart quote, a non-breaking space or a stray line break inside it. It looks identical and matches nothing. If a value you are certain about refuses to work, run it through a text cleaner before you blame the hash.
There is also a limit the tool deliberately does not cross. It generates bcrypt only. If your site overrides the algorithm through the wp_hash_password_algorithm filter, which core exposes so you can select Argon2i or Argon2id where PHP offers them, the $wp value here will not be what your install would have produced. Note that the bare MD5 route still works in that case, because the length branch runs first. The tool also does not verify an existing hash against a password: bcrypt verification needs the stored salt, and reading a hash out of a live users table into a web page is not a habit worth building.
MD5 is dead for passwords and perfectly good for checksums
People hear “MD5 is broken” and conclude it should never appear anywhere. That is not quite the shape of the problem. MD5 is broken in two specific ways: it is collision-prone, meaning two different inputs can be constructed to share a digest, and it is fast, meaning a modern GPU tries billions of candidate passwords per second against it.
Speed is what kills it for passwords. A password hash is meant to be slow on purpose, and bcrypt’s cost is a power of two: cost 12 means 2^12 key schedule rounds. Timed on this server’s PHP 8.5, one bcrypt hash at cost 12 takes 0.164 seconds and at cost 10 takes 0.041 seconds, while the same PHP grinds through 200,000 MD5 digests in 0.026 seconds. That is over a million times more guesses per second, before an attacker moves off a CPU and onto a GPU. The delay is invisible when a human logs in once and ruinous when a machine works through a leaked table.
Neither weakness matters when the question is “did this file arrive intact”. Nobody is constructing a collision against your holiday photos, and you want the check to be fast. That is why MD5 and CRC32 both still appear in the tool. CRC32 is not even a cryptographic hash: it is a 32 bit error detection code from network frames and zip archives, good at catching a truncated download and useless against deliberate tampering.
Salt, HMAC, and the thing base64 is not
A salt is random data mixed into a hash so that identical passwords produce different stored values. Without one, every account using the same weak password shares the same digest, and an attacker who cracks it once has cracked all of them at no extra cost. With one, each row has to be attacked separately. bcrypt carries its salt inside the hash string itself, which is why a stored value contains the algorithm, the cost, a 22 character salt and only then the digest. The tool draws that salt from crypto.getRandomValues(), and the 22 characters it shows round-trip exactly to the 16 random bytes used, so the value it produces genuinely verifies.
Those are not the same thing as the eight salt constants in wp-config.php. AUTH_KEY and its siblings are secrets for signing login cookies and nonces. They have nothing to do with password storage, and rotating them logs everyone out without touching a single user_pass value. Two different meanings of one word, in two different files.
An HMAC is a hash with a key. Give it the same message and a different key and you get a completely different digest. It answers a question a plain hash cannot: not “what is the fingerprint of this data”, but “was this produced by someone holding the secret”. Webhook signatures are the everyday case: a service sends a payload plus an HMAC of it under a shared secret, and you recompute it to confirm the payload is really from them and arrived unmodified. WordPress uses one for a narrower job, domain separation on the password pre-hash, which is why its key is a fixed public string rather than a secret. Enter a key in the tool and the four HMAC-SHA rows appear alongside the plain digests.
Base64 is none of this. It is a way of writing arbitrary bytes using 64 printable characters, invented so that binary data could pass through systems that only handle text, such as email bodies and data URIs. It is fully reversible by anyone, with no key involved. A base64 string is not protected, it is packaged. The same goes for hex, percent-encoding and HTML entities: all four are transport formats, all four sit in the tool’s encoding panel with encode and decode directions, and none of them keeps a secret. Treat base64 as a way to hide an API key and that key is public.
Direction is the whole difference. A hash goes one way and stays there, which is why user_pass cannot be read back into a password. Encoding goes both ways by design. The decode direction in the tool is text only: with a file loaded it refuses and says so.
Getting back in, in order
The sequence is short. Open the database with the credentials from wp-config.php, find your row in wp_users by user_login, and write either the bare MD5 or the full $wp value into user_pass. The column is varchar(255), so length is never the constraint. Log in immediately: if you used the MD5, core replaces it during that same request. Then fix the mail, because it will fail again next time.
What makes this recovery rather than an attack is not the technique, which is identical either way. It is that you already hold the database credentials. Anyone with write access to wp_users could equally read your posts, drop your tables or install whatever they liked. The password column is not the boundary on that server; the credentials are. Which is why the tool ships an own-site warning next to the WordPress output.
Once you are back in, the rest of this is ordinary working equipment: a checksum for a file you have just moved, a base64 string to unpick from an inherited config, an HMAC to reproduce while chasing a webhook that keeps returning 401. Files are read with FileReader and capped at 32MB, the byte view shows the first 512 bytes, and there is no request in the page for a password to travel on.