A WordPress nonce is a 10-character hash of four things: a time window, an action name, the current user ID, and the current session token. It is not a number, it is not used once, and it does not last 24 hours.
Most guides stop at "nonces expire after a day, so re-generate them". That explains almost none of the nonce failures people actually hit, because the common causes are the session token and the salts, not the clock.
What a nonce is made of
wp_verify_nonce() in wp-includes/pluggable.php is short enough to read in full, and it is the whole story:
$token = wp_get_session_token();
$i = wp_nonce_tick( $action );
// Nonce generated 0-12 hours ago.
$expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
return 1;
}
// Nonce generated 12-24 hours ago.
$expected = substr( wp_hash( ( $i - 1 ) . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
return 2;
}
Nothing is stored. WordPress recomputes the hash from the four inputs and compares it, so a nonce fails the moment any one of those inputs changes.
That is the useful mental model. Instead of asking "has it expired", ask "which of the four inputs is different now".
Why the 24 hours in every guide is wrong
The time window is wp_nonce_tick(), which is ceil( time() / ( $nonce_life / 2 ) ). With the default nonce_life of DAY_IN_SECONDS, the tick increments every 12 hours.
Verification accepts the current tick and the one before it. A nonce therefore survives until the tick it was created in falls two behind, which is somewhere between 12 and 24 hours later depending on where in the window it was created.
A nonce created one second before a tick boundary is valid for 12 hours and one second. A nonce created one second after is valid for just under 24 hours.
Two things follow that matter in practice. The average real lifetime is 18 hours, not 24, so any retry logic built on a 24-hour assumption is wrong roughly half the time.
And the boundary is global, computed from Unix time with no per-user component. Every nonce on the site ages out at the same two wall-clock moments each day, which is why "all our users got logged out of the form at once" reports cluster rather than spread.
wp_verify_nonce() does not return true
It returns 1, 2 or false. A strict comparison against true fails for every valid nonce, and this is a real bug people ship:
if ( wp_verify_nonce( $_POST['_wpnonce'], 'my_action' ) === true ) { // never runs
Use a loose truthiness check, or compare against false. The difference between 1 and 2 is only which tick matched, and 2 is worth logging if you want to know how stale your forms get before submission.
The four things that kill a nonce before it expires
This is the part the expiry discussion hides. Each of these invalidates every outstanding nonce instantly, with no clock involved.
The session token changes. wp_get_session_token() reads the token out of the logged_in auth cookie, so logging out, logging in again, or using "Log out of all other sessions" produces a different token and breaks every nonce generated under the old one.
The user ID changes. A nonce generated while logged in as user 3 will not verify for user 7, which is why nonce failures show up in testing when someone switches accounts in another tab.
The salts change. wp_hash( ..., 'nonce' ) salts with NONCE_KEY and NONCE_SALT, so editing those constants in wp-config.php invalidates every nonce on the site at once. We wrote about what each salt actually does separately, and nonce breakage is the most visible symptom of rotating them.
The user is logged out. Then $uid is 0 and $token is the empty string, so the hash is built from tick|action|0|. Every logged-out visitor gets the identical nonce string for the same action in the same tick, which is the point most guides skip.
That last one cuts both ways. A guest nonce is safe to serve from a page cache because it is the same for everyone anyway, while a logged-in nonce in a cached page is served to the wrong session and fails.
If you need guest nonces to be per-visitor, the nonce_user_logged_out filter is the supported hook for substituting an identifier.
"Are you sure you want to do this?"
That screen is wp_nonce_ays(), triggered by check_admin_referer() when verification fails. It is deliberately vague, which means the error message tells you nothing about which of the four inputs was wrong.
To find out, hook the failure instead. wp_verify_nonce_failed has fired since WordPress 4.4 and hands you the nonce, the action, the user object and the token:
add_action( 'wp_verify_nonce_failed', function ( $nonce, $action, $user, $token ) {
error_log( sprintf( 'nonce fail: action=%s uid=%d token=%s', $action, $user->ID, $token ) );
}, 10, 4 );
Log that on a throwaway copy of the site, reproduce the failure, and the uid and token in the log tell you immediately whether the session changed or the clock ran out.
A nonce is not a permission check
This is the mistake with real consequences. A nonce proves a request came from a page your site rendered, and nothing else.
A subscriber who loads an admin screen they can see will get a perfectly valid nonce for any action on it. If the handler checks the nonce and not the capability, that subscriber can perform the action.
Always pair them:
if ( ! current_user_can( 'edit_posts' ) || ! check_admin_referer( 'my_action' ) ) {
wp_die( 'Nope' );
}
Reproducing nonce behaviour without guessing
Nonce bugs are miserable to debug on a live site because the fastest test is to wait 12 hours or log everyone out. On a disposable install you can collapse both into seconds.
Every SandyWP sandbox has WP-CLI preinstalled over SSH, so wp eval gives you a direct line to the functions above. Start by watching the tick move:
wp eval 'echo wp_nonce_tick() . PHP_EOL;'
wp eval 'add_filter("nonce_life", fn() => 20); echo wp_nonce_tick() . PHP_EOL;'
With nonce_life at 20 seconds the tick advances every 10, so a full expiry cycle you would otherwise wait a day for runs while you watch.
The second trap is easier to hit than the expiry. wp eval runs with no logged-in user unless you pass --user, so it generates logged-out nonces by default:
wp eval 'echo wp_create_nonce("my_action") . PHP_EOL;'
wp eval --user=1 'echo wp_create_nonce("my_action") . PHP_EOL;'
Those two strings differ, and the second one still will not match a nonce from your browser, because the CLI has no session token from your logged_in cookie. If a nonce generated on the command line does not verify in the browser, that is why, and it is not a bug in your code.
To prove the salt behaviour, change NONCE_SALT and watch everything break at once:
wp config set NONCE_SALT "$(head -c 48 /dev/urandom | base64)"
Reload any admin screen with an outstanding form and it fails immediately, with no clock involved.
Turn on WP_DEBUG and the debug log while you do this, so the wp_verify_nonce_failed output above lands somewhere you can read it.
When a sandbox is the wrong tool here
If the nonce failures are only happening on one production site, reproducing them on a clean install will usually tell you nothing. The cause is almost always something environmental: a page cache serving a logged-in response, a security plugin filtering nonce_life, or a reverse proxy stripping cookies.
For that, clone the real site with its plugins and configuration rather than starting from a fresh WordPress, or debug it where it lives. A bare sandbox is for understanding the mechanism, not for reproducing an environment you have not copied.
The short version
Nonces last between 12 and 24 hours, averaging 18, and expire on a global boundary rather than per user. wp_verify_nonce() returns 1 or 2, never true.
Session token, user ID and salts each invalidate every nonce instantly, and those three cause more failures than expiry does. And a valid nonce says the request came from your site, not that the person sending it is allowed to do the thing.
