← Back to blog

WordPress salts: what they actually sign, and what breaks when you change them

The SandyWP team 7 min read

WordPress salts are keying material for HMAC. They sign your login cookies and your nonces, and nothing else in core touches your users' passwords.

That matters because the advice you find is "rotate your salts, everyone gets logged out, no harm done". Logging everyone out is the visible part. The quiet part is a block that stops rendering and a set of stale rows in usermeta.

All core code below was read from WordPress/wordpress-develop trunk on 2026-09-26.

Salts do not salt your passwords

This is the most repeated claim about them and it has not been true for years. wp_hash_password() in wp-includes/pluggable.php never calls wp_salt(). Since 6.8 it HMACs the password with sha384, base64s the result, then runs bcrypt, which generates and stores its own per-password salt inside the hash string.

So rotating AUTH_SALT does not invalidate a single password, and it gives you nothing against a leaked wp_users table. The user_pass column is exactly as strong the moment after a rotation as the moment before.

What the eight constants do feed is one function:

function wp_hash( $data, $scheme = 'auth', $algo = 'md5' ) {
	$salt = wp_salt( $scheme );
	return hash_hmac( $algo, $data, $salt );
}

wp_salt( $scheme ) returns the key and the salt for that scheme concatenated. There are four schemes, auth, secure_auth, logged_in and nonce, which is why there are eight constants: a _KEY and a _SALT for each.

Your salts may not be in wp-config.php

wp_salt() does not simply read the constants. It builds a $duplicated_keys map first, and a constant is thrown away in three cases: it is undefined, it is the literal put your unique phrase here, or its value appears in more than one constant.

$duplicated_keys[ $value ] = isset( $duplicated_keys[ $value ] );

That third case is the one that catches people. Paste the same random string into AUTH_KEY and AUTH_SALT, which is easy to do by hand, and core treats both as unusable.

When a value is unusable, core generates a replacement and writes it to the database:

$values[ $type ] = get_site_option( "{$scheme}_{$type}" );
if ( ! $values[ $type ] ) {
	$values[ $type ] = wp_generate_password( 64, true, true );
	update_site_option( "{$scheme}_{$type}", $values[ $type ] );
}

So a site with a half-filled wp-config.php keeps part of its keying material in wp_options, where it travels inside every database dump and every export. Editing wp-config.php then does not rotate those schemes at all, because the options row still wins for any constant core rejected.

Check before you assume. On a live install:

wp option get auth_salt
wp option get nonce_salt
wp option get secret_key

A value coming back means that scheme is keyed from the database, not from your config file.

What a rotation actually invalidates

Four things, and they are worth separating because they fail in different ways.

Login cookies. wp_generate_auth_cookie() builds its key from wp_hash( $user_login . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ). New salt, different key, every existing cookie fails validation. This is the logout everyone writes about.

Nonces. wp_create_nonce() is substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 ). Any form or admin page a user already had open dies with "Are you sure you want to do this?" on submit. Nonces last a day by default (wp_nonce_tick() halves DAY_IN_SECONDS), so this clears itself.

Legacy widget blocks. This is the one nobody mentions, and it is not self-healing. A core/legacy-widget block stores its settings as a base64 payload plus a MAC in the post content, and wp-includes/blocks/legacy-widget.php checks it on every render:

if ( ! hash_equals( wp_hash( $serialized_instance ), (string) $attributes['instance']['hash'] ) ) {
	return '';
}

The MAC was computed with the old salt. After a rotation it never matches again, and the block renders an empty string. No error, no notice, no fatal. The widget is just gone from the page, and re-saving it in the editor is the only fix. The REST side fails louder: class-wp-rest-widgets-controller.php returns rest_invalid_widget, "The provided instance is malformed."

Comment preview links. wp_get_unapproved_comment_author_email() verifies moderation-hash against wp_hash( $comment->comment_date_gmt ). This one is genuinely harmless, because the link only works for ten minutes anyway.

What survives, which is more than you would think

Password reset links survive. Since 6.8 get_password_reset_key() stores wp_fast_hash() of the key, which is BLAKE2b and takes no salt. A link mailed out before the rotation still works after it.

Application passwords survive, for the same reason: they are stored with wp_fast_hash() too. If you are rotating salts because you think a credential leaked, the rotation does not revoke a single integration. Go and delete them under Users, Profile.

Password-protected post cookies survive. post_password_required() verifies the wp-postpass_ cookie with phpass directly, not through wp_hash().

And the session rows survive. WP_Session_Tokens::hash_token() is a bare hash( 'sha256', $token ) with no salt, so every entry in the session_tokens user meta is still there after a rotation. They are unusable, because the cookie carrying the token can no longer be validated, but nothing prunes them either: update_sessions() only runs when a session is created or destroyed. The rows sit in usermeta until that user's next login rewrites the array and is_still_valid() filters the expired ones out.

That is untidy rather than dangerous. It is also the reason a rotation is not a substitute for "Log Out Everywhere Else" when you are cleaning up after an incident.

How to rotate them

If you are on WP-CLI, one command edits the file:

wp config shuffle-salts

It rewrites the eight core constants in place. Pass specific key names to do a subset, and --force to add a key that is not one of core's. Otherwise, paste a fresh block from https://api.wordpress.org/secret-key/1.1/salt/ over the old one.

Then, if the earlier wp option get check found anything, clear the database side too, or the old value keeps winning for that scheme:

wp option delete auth_salt

Rehearse it on a copy first

The failure mode here is not a white screen you notice immediately. It is a widget that quietly stopped rendering on one page, found three weeks later.

So do the rotation on a throwaway copy of the site before the real one, and check the specific things above rather than just loading the homepage. A SandyWP sandbox gives you a full copy with its own database, and SSH and SFTP or the CLI get you to wp-config.php and to WP-CLI on it.

Worth knowing if you work this way: every SandyWP sandbox is created with all eight constants set, derived per site, so a copy never shares keying material with the site it came from. Rotating on the copy tells you what the rotation breaks without telling you nothing, which is what happens if the copy shared the original's salts.

The check after rotating, in order:

  1. Log in. Confirm you are asked to.
  2. Load every page or template that contains a legacy widget block and confirm it still renders. This is the one that does not announce itself.
  3. Open the widgets screen and save each widget once if anything is missing.
  4. Submit one admin form to confirm nonces regenerate cleanly.
  5. Run a password reset and confirm the mail arrives, which is where a captured mail log is quicker than checking an inbox.

When it is worth doing at all

After a confirmed compromise, yes, as part of a larger cleanup: rotate salts, force logouts, delete application passwords, change passwords. The salt rotation on its own closes stolen-cookie reuse and nothing else.

On a schedule, no. There is no attack a quarterly rotation prevents that the password hashing does not already handle, and each rotation carries the widget risk for no gain.

If what you actually want is "log everybody out", WP-CLI does that without touching the keys: wp user list --field=ID | xargs -n 1 wp user session destroy --all. It is narrower, it clears the session_tokens rows properly rather than orphaning them, and it leaves your widget MACs alone.

Related reading: resetting a WordPress password covers why a reset does not end existing sessions either, and application passwords covers what those credentials do and do not scope.