Near the top of every wp-config.php there are eight define() lines that somebody pasted in once and never looked at again. They are not passwords. Nothing checks them when you log in, no admin screen will ever show them to you, and no update touches them. They are the HMAC keys that sign every authentication cookie and every nonce your site hands out.
That gets concrete the day a copy of the file escapes. A backup archive left in the web root, a hosting login a former contractor still has, a repository that was public for one afternoon: any of them hands over eight strings that are still, right now, the only thing separating a forged admin cookie from a real one. Changing your password does not touch them. Reinstalling WordPress does not touch them. Somebody has to open the file and replace them.
The quieter failure is having no constants at all. WordPress does not complain when they are missing. It calls wp_generate_password( 64, true, true ), writes the result into the options table and carries on. The most sensitive secret on the site then lives in the database, inside every dump that ever gets emailed to a developer.
The WordPress salt generator below writes the eight lines, plus the two other secrets that tend to come up in the same afternoon: a password with an honest entropy figure attached, and an .htpasswd line for a staging site.
WordPress salts and passwords
Three secrets a WordPress install needs, all made here in this browser tab with crypto.getRandomValues. Nothing is sent anywhere, there is no request to a server and no logging, because a secret that travels to a server is not a secret any more.
1The eight constants for wp-config.php
Replace the whole block between AUTH_KEY and NONCE_SALT. Changing them logs every user out, which is exactly what you want after a break-in.
Each value is 64 characters from printable ASCII, 92 of them: the single quote and the backslash are left out so the line stays valid inside single quotes in PHP. That is 417.5 bits per constant.
2A password
For an administrator account, a database user, an FTP login. Every character is an independent draw, with no rule forcing one of each kind, because such a rule would lower the entropy and make the number below a lie.
Pick at least one kind of character, there is no alphabet to draw from.
0bits
The symbol set is !@#$%^&*()-_=+[]{};:,.?/~, which leaves out the quotes, the backslash, the backtick and the space, so the password survives being pasted into a shell command or a configuration file. Excluding look-alikes drops the zero and both letter O's, the one together with a lower case L and a capital i, the five together with a capital S, and the eight together with a capital B.
3An .htpasswd line
For locking wp-login.php or wp-admin behind the web server, before WordPress is even reached. The hash is APR1-MD5, worked out here, with the salt taken from crypto.getRandomValues.
Where the server runs Apache 2.4 or newer, bcrypt is the better choice and htpasswd -B -C 12 .htpasswd name writes it. APR1 is 1000 rounds of MD5, which a modern graphics card chews through by the million every second, and it is offered here only because every Apache understands it, including the old ones. This tool does not produce bcrypt: that needs a Blowfish implementation, and a subtly wrong one would be worse than none at all.
All of it runs in the browser tab. There is no network call anywhere in the tool, so the values you copy have never crossed a wire, sat in a server log or turned up in anybody’s request history. For a value whose entire job is to be unknown to everyone else, that is not a nicety.
What the eight constants actually sign
WordPress has four authentication schemes, not eight: auth, secure_auth, logged_in and nonce. Each gets a key and a salt. wp_salt( $scheme ) in wp-includes/pluggable.php looks up both, concatenates them key first, and returns one string. wp_hash() uses that string as the HMAC key, and nothing else in core ever sees it.
// wp-includes/pluggable.php, the last line of wp_salt()
$cached_salts[ $scheme ] = $values['key'] . $values['salt'];
// and the whole of what wp_hash() does with it
function wp_hash( $data, $scheme = 'auth', $algo = 'md5' ) {
return hash_hmac( $algo, $data, wp_salt( $scheme ) );
}
The three cookie schemes map onto three cookie names, all built in wp-includes/default-constants.php out of COOKIEHASH, which is nothing more than an MD5 of the site URL. auth signs wordpress_[COOKIEHASH], the admin cookie for plain HTTP. secure_auth signs wordpress_sec_[COOKIEHASH], the same cookie once the admin area is on HTTPS, which on any site built this decade makes it the pair that matters. logged_in signs wordpress_logged_in_[COOKIEHASH], which travels with every front end request and tells your theme who is reading.
The cookie value is four pipe separated fields: login, expiry, session token, and a SHA-256 HMAC over the first three. The key for that HMAC is not the salt directly. It is wp_hash( login|pass_frag|expiration|token, $scheme ), where pass_frag is four characters lifted out of the user’s stored password hash. A live cookie is therefore bound to three things: the salt in the config file, the user’s current password hash, and one session token row in user meta. Break any one and the cookie stops verifying.
The fourth scheme signs nonces, and a nonce is smaller than people expect. wp_create_nonce() returns ten characters cut out of an HMAC: substr( wp_hash( $tick . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 ). The tick is ceil( time() / ( DAY_IN_SECONDS / 2 ) ), and that halving is why core documents the lifetime as “between 12 and 24 hours” rather than a fixed number. Every _wpnonce field in the dashboard and every X-WP-Nonce header the block editor sends is signed with NONCE_KEY and NONCE_SALT.
Keys and salts are not two different things
The naming implies a cryptographic distinction. There is not one, at least not any more. WordPress 2.5 shipped a single SECRET_KEY; 2.6 added the four pairs still in the sample file today, the idea being that the key lived in the file and the salt lived in the database. Modern core glues them together into one long HMAC key. Both halves come out of the same generator, both are 64 characters, and neither deserves more care than the other.
What core does care about is whether a value is real. Before reading anything, wp_salt() builds a list of duplicated values, marks put your unique phrase here as duplicated (in English and in the translated form used by localised sample files), then treats any constant whose value appears more than once as though it were never defined. Pasting the same 64 characters into all eight lines does not give you eight secrets. It gives you none: core falls back to the database for every one of them, generating and storing one there instead.
The moment you change them
Everybody is logged out, including you. That is not a side effect to work around, it is the entire feature.
The mechanism explains what survives. An existing wordpress_logged_in_ cookie carries a session token, but the token is only reached once the HMAC verifies. Change the salt and it does not verify, so the cookie is thrown away before the token is ever looked up and the visitor is anonymous on the next request. The session rows in user meta are simply orphaned.
Passwords are untouched. wp_hash_password() runs the password through PHP’s password_hash() with PASSWORD_BCRYPT (bcrypt since WordPress 6.8, phpass before that), and no salt constant appears in it. Application passwords are untouched too: since 6.8 they go through wp_fast_hash(), a BLAKE2b hash from libsodium keyed with a fixed literal string. Posts, media, options, plugin settings and orders do not know the salts exist.
What genuinely stings is nonces in flight. Anyone with an editor open at the moment you save the file gets a failed autosave (rest_cookie_invalid_nonce from the REST API) or the classic “Are you sure you want to do this?” screen from an older admin form. Nothing is destroyed: their text is still in the browser. But if five people are working, do it at a quiet hour and tell them first.
Where the values come from
The official source is the secret key service at api.wordpress.org/secret-key/1.1/salt/, which is exactly what the comment block in wp-config-sample.php sends you to. It is fine. Served over TLS, real random source, twenty years of history. WP-CLI’s wp config shuffle-salts fetches from the same endpoint, which is why that command carries an --insecure flag for retrying the download when a TLS handshake fails.
Fine is not the same as best available. A remote generator means the bytes that will sign your admin cookies were produced on a machine you do not control, crossed a network, and then passed through whatever sits between the response and the file: terminal scrollback, a clipboard manager, browser history. None of that is likely to be what gets a site compromised. All of it is avoidable for nothing.
Then there is the edit itself. If you are unsure what else belongs in that file, wp-config.php explained covers the constants that earn their place. Two notes specific to this edit: use an editor that will not helpfully convert straight quotes into curly ones, and check that no stray apostrophe has landed inside a value, because an unescaped quote inside a single quoted PHP string is a parse error, and a parse error in wp-config.php is a white screen on every page of the site including the login form.
Why Math.random is disqualified
Every WordPress salt generator has to answer one question: where do the bytes come from. Math.random() is not a cryptographic generator and has never claimed to be. V8 implements it as xorshift128+ with 128 bits of state, seeded once per context and never reseeded, and the language specification explicitly declines to promise anything about predictability. Its internal state can be recovered from a modest run of observed outputs. Pointing it at a value that signs an admin cookie is an unforced error, and plenty of generators still do.
The right primitive is crypto.getRandomValues(), the platform’s own CSPRNG. Then comes the subtler half. Take a random byte modulo 92 and you have quietly broken uniformity: 256 divided by 92 leaves a remainder of 72, so the first 72 characters of the alphabet arrive on three byte values each while the remaining 20 arrive on two. Rejection sampling is the fix. Work out 256 minus (256 modulo 92), which is 184, discard any byte from 184 upwards and use the rest. The tool does that on every draw, so alphabets that do not divide 256 stay uniform, and Math.random appears nowhere in it.
The salt alphabet is printable ASCII from 33 to 126 with the single quote and the backslash removed, because either would need escaping inside a single quoted PHP string. That leaves 92 characters, 6.52 bits each, 417.5 bits for a 64 character constant. Nobody needs to argue about whether that is enough. The eight lines come out padded into the same column wp-config-sample.php uses, so the block drops straight over the old one.
The password section runs the same machinery over a smaller alphabet and shows its working. The entropy figure is length multiplied by the base 2 logarithm of the alphabet the tool is drawing from at that moment, which is why it moves when you flip a class off. There is deliberately no rule forcing one character from each selected class: such a rule shrinks the space being drawn from, which would make the number on screen an overstatement, and the page says so.
The No look-alikes switch drops 0 O o 1 l I 5 S 8 B from whichever classes are on. It is a readability decision with a measurable price: with letters and digits enabled the alphabet falls from 62 characters to 52, a quarter of a bit per character. Over twenty characters that is about five bits, a factor of thirty four in guessing effort, and one extra character buys all of it back. Turning all four classes off is handled rather than crashed: an error line appears, the password and entropy readout clear, and the two buttons disable.
The time figure underneath is stated against a specific attack, a trillion offline guesses a second against a stolen hash, not against a login form with rate limiting in front of it. The arithmetic stays in log10 from end to end, because a guess count with a couple of hundred digits stops being an exact number in a JavaScript double long before it stops being a number worth printing.
The .htpasswd line for a staging site
The third section writes one line of an .htpasswd file: a user name, a colon, and an APR1-MD5 hash. The format is $apr1$, an eight character salt, then a 22 character digest, and the hash is Apache’s own key stretching loop: a thousand MD5 rounds remixing the password, the salt and the previous digest. The tool implements MD5 and that loop itself, and the output was checked against openssl passwd -apr1 on twelve vectors: the empty password, one and two characters, 15, 16 and 17 characters (either side of MD5’s 16 byte block boundary), a 34 and a 64 character password, symbol heavy and UTF-8 passwords, and salts containing a dot and a slash. All twelve matched, and htpasswd -vb accepts a line the shipped code produced.
Two behaviours there are deliberate. The salt survives edits to the user name and the password, so the line updates live as you type and only New salt re-rolls it, which incidentally makes the hash testable: same inputs, same line, twice. And a user name containing a colon, whitespace or a control character is refused with the reason given. The colon is the field separator in that file, so a name containing one writes a record Apache cannot parse.
What the tool will not do is bcrypt. Correct bcrypt needs a Blowfish implementation, and a subtly wrong one would be worse than none, so where Apache 2.4 or newer is available the page points you at htpasswd -B -C 12 instead. Be clear about what APR1 is: a thousand rounds of MD5 is not a strong password hash by any modern standard. For a gate on a staging copy it is adequate. For anything holding real accounts it is not.
HTTP authentication is a fence, not a lock
Basic auth stops search engines, casual visitors and anybody following a stale link into your staging copy. It does not stop somebody who has the credentials, and those credentials go out as base64 of user:password in a header on every request, which is not encryption and reverses in one command. Over plain HTTP that is a password in clear text on every page load. Put the staging site behind HTTPS or do not bother with the fence.
It also breaks things you will have forgotten about. Anything reaching the site without a browser now gets a 401: payment gateway webhooks, deploy hooks, uptime monitors, and WordPress’s own loopback request to wp-cron.php, an ordinary HTTP request the site makes to itself, which the fence rejects like any other. A staging site that stops publishing scheduled posts is usually this, and why WP-Cron is not a cron job covers the rest of that failure. The repair is a real system cron with DISABLE_WP_CRON set, or an allow rule for that one file.
The first move after a break in
If a site has genuinely been broken into, rotating the salts is one of the earliest useful actions available, because it evicts every session at once. An intruder holding a stolen cookie but not a password loses access the second you save the file. It costs nothing, takes ten seconds, and does not depend on knowing yet how they got in.
It is also not a fix by itself. If they still have write access to the filesystem they can read the new constants as easily as the old ones, and an early rotation mostly tells them you noticed. The order that works: take the site off the public network or into maintenance, find and remove the persistence (a modified theme functions.php is one of the classic hiding places, and functions.php explained is a tour of what should and should not be in that file), then rotate the salts, then reset passwords and revoke application passwords, then let people back in. Rotate first and clean second and you will only be doing it twice.
Those eight lines are the only secret on a WordPress site with no interface, no expiry date and no reminder attached. Nothing in the dashboard will ever mention that they are eleven years old, or shared with two other installs because the site was cloned twice, or living quietly in wp_options where the next database export picks them up.
So give them some attention on a schedule you will actually keep. Rotate when a site is cloned for staging, because the clone inherits the original’s secrets and that is the same as sharing a password. Rotate when someone with server access leaves. Rotate the moment anything looks like an intrusion. It is a two minute edit with exactly one predictable consequence, everybody signs in again, and that consequence is the thing you were trying to cause.
And generate the replacements somewhere they cannot leak on the way to the file: local, no request, clipboard, paste, save. The whole point of a secret is the list of people who have seen it, and that list should be one name long.