Authorisation
Nonces Prove Intent, Not Permission
What a nonce proves about a request, why a stale one looks like a random logout, how a page cache turns it into decoration, and the check that must follow.
A support ticket says the site logs people out at random. It happens on one screen, a settings form or a dashboard widget, and the person reporting it is usually describing the page they land on rather than what happened. They clicked save, the response was a 403 page reading "The link you followed has expired", and they concluded their session died.
The second population with the same ticket is administrators on a site with an aggressive page cache. It fails on the first click after a cache flush, then works, then fails again the next day. Two causes, one symptom, and in both cases the nonce did what it was built to do.
What it was built to do is narrow. A WordPress nonce is a CSRF token: it proves the request came from a page this site rendered to this session, which is not the same as proving the session may do the thing. That distinction is why "we check a nonce" survives review on a handler any account can call.
A nonce is a hash of four things, and the user is one of them
The name suggests a value consumed by a single use. It is not. wp_create_nonce( $action
) hashes four values together and returns ten characters of the result: the current tick,
the action string, the user ID, and the session token. Verification rebuilds the hash from the
current request and compares.
wp_create_nonce( $action ) = substr( wp_hash( $tick . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 )
Run against the WordPress 7.0.7 source with a fixed salt, the consequences of those four inputs are visible directly. For one user and one session token:
same user, same action, same token, called twice b47b431913 (identical)
same user, same action, rotated session token 510f64797f (differs)
different user, same action and token c2238a642c (differs)
same user, different action 784539628f (differs)
Because the user ID is an input, a nonce minted for an administrator does not verify for a
subscriber. Because the session token is an input, a nonce issued before this account logged in
elsewhere stops verifying, which is how a re-login invalidates the forms in your other tabs.
Because the action string is an input, a nonce minted for acme_save_settings does not
verify for acme_delete_settings, so a token scraped from one form cannot drive
another.
And because nobody is an input when nobody is logged in, an anonymous nonce is the same string
for every visitor during a tick: the user ID is 0 and the session token is empty. That
is the reason a nonce on a wp_ajax_nopriv_ handler has the security properties of a
comment. There is no session to bind the token to, so it identifies the action and the clock and
nothing else.
The window is two ticks, and the second one is where forms go to die
wp_nonce_tick() divides the current time by half of the nonce lifespan, which comes
from the nonce_life filter and defaults to DAY_IN_SECONDS. One tick is
43,200 seconds, so the tick index changes every twelve hours. Verification accepts a nonce from the
current tick and from the previous one, and that is what the return value reports:
1 for this tick, 2 for the last one, false for neither.
Measured against the real functions:
tick length 43200 s
nonce minted in the current tick wp_verify_nonce() -> 1
nonce minted in the previous tick wp_verify_nonce() -> 2
nonce minted two ticks ago wp_verify_nonce() -> false
nonce minted in the next tick wp_verify_nonce() -> false
wrong action wp_verify_nonce() -> false
correct action, different user wp_verify_nonce() -> false
correct action, rotated session token wp_verify_nonce() -> false
empty string wp_verify_nonce() -> false
So a nonce is good for between twelve and twenty-four hours, depending on where in the tick it was minted, and a page left open past that boundary submits a value that no longer verifies. An edit screen in a background tab overnight, an order form filled in across a shift change, a dashboard widget that renders once and posts an hour later. None of those is an attack, and all of them fail.
What the failure looks like depends on which check the handler used. check_admin_referer(
'acme_save' ) calls wp_nonce_ays() and stops, which produces a 403 page. For
the default action, 'log-out' is special-cased and offers a logout link, and every
other action gets "The link you followed has expired". check_ajax_referer( 'acme_sync',
'nonce' ) with the default $stop = true in a non-AJAX context calls
die( '-1' ), so the user gets a page containing -1. Neither response says
anything about a nonce, which is why the report arrives as "it logged me out".
The fix is not a longer window. It is failing in a way the user can act on, and not depending on a token the browser has been holding since the page rendered.
// Fragment of a fictional plugin. Not runnable on its own.
add_action( 'wp_ajax_acme_save_settings', 'acme_handle_save_settings' );
function acme_handle_save_settings() {
// $stop = false: return false instead of dying, so this handler can answer.
if ( ! check_ajax_referer( 'acme_save_settings', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => 'This page was open too long. Reload it and try again.' ),
403
);
}
// ... capability check, then sanitisation, then the write.
}
A nonce in a cached page is a nonce that guards nobody
Full-page caching stores rendered HTML and serves it to the next visitor. If that HTML contains a nonce, the stored copy belongs to whoever triggered the cache fill, and everybody else is handed their token. For a logged-out visitor the token is derived from the action and the tick alone, so the guard either rejects every submission from a stale copy or accepts a value that every other visitor already has.
The worse version is a cache that hands one logged-in user's page to another. The token is real and the hash matches, so the check passes, and it passes for a request that account never made. That is the case where caching stops being a performance mistake and becomes a security one.
The test takes a minute and needs two browsers. Load the screen in a private window with the
cache cold and save the HTML. Flush the page cache. Load the same URL logged out, or as a second
account, and diff the two name="_wpnonce" values. Identical values mean the page is
cached and the nonce is riding along inside it. The same test applies to a REST nonce printed by
wp_localize_script(), a token in a settings link, and an unauthenticated form on the
front end.
The fix is structural. Either exclude the page from full-page caching, or stop rendering the nonce into it and fetch one immediately before the write. The second option keeps the page cacheable and shortens the token's life from hours to seconds.
Three traps survive a correct mental model
The first is a token in a cached response, which is no longer a secret only the intended session holds. Treat any nonce that appears in a shared cached response as public.
The second is check_admin_referer() called without an action. Its first parameter
defaults to -1, and when the action is -1 and the referring URL starts
with the admin URL, the function skips the nonce result entirely. Pass an action, and pass the same
string used to mint the token.
// Wrong: no action, so the parameter defaults to -1.
check_admin_referer();
// Wrong for a subtler reason: the token on the page was minted for a different
// action string, so verification fails and the handler stops with a 403.
wp_nonce_field( 'acme_save_settings' );
// ... later, in the handler ...
check_admin_referer( 'acme_save' );
// Right: one string, used in both places.
check_admin_referer( 'acme_save_settings' );
The third is the return value. wp_verify_nonce() returns an integer or
false, so if ( true === wp_verify_nonce( $nonce, $action ) ) is false for
a perfectly valid nonce. That comparison fails closed rather than opening a hole, which is the best
thing that can be said for it. Compare against false, or let
check_admin_referer() and check_ajax_referer() do the comparison.
Scope matters here too. On a REST request that authenticates with cookies, core validates the
X-WP-Nonce header itself. A request with no nonce header is treated as unauthenticated,
so the route's own permission check answers, often with a 401. A request with a bad nonce is
rejected with rest_cookie_invalid_nonce before your callback runs. Neither outcome is
authorisation, and neither replaces the permission callback.
A nonce is always paired with a capability check
The failure mode is deleting half of the rule. The nonce goes first and establishes that the request was intended by this session. The capability check goes second and establishes that this session may perform the operation. Neither answers the other's question.
add_action( 'wp_ajax_acme_save_settings', 'acme_handle_save_settings' );
function acme_handle_save_settings() {
if ( ! check_ajax_referer( 'acme_save_settings', 'nonce', false ) ) {
wp_send_json_error( 'This request could not be verified.', 403 );
}
if ( ! current_user_can( 'acme_manage_settings' ) ) {
wp_send_json_error( 'You are not allowed to change these settings.', 403 );
}
$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
if ( ! in_array( $mode, array( 'live', 'test' ), true ) ) {
wp_send_json_error( 'Unknown mode.', 400 );
}
update_option( 'acme_mode', $mode );
wp_send_json_success( array( 'mode' => $mode ) );
}
Delete the second check and the nonce still passes for every logged-in account on the site. A
subscriber who reads the action name out of the page source and posts to
admin-ajax.php directly carries a valid nonce, because core minted it for that
subscriber's session. The nonce did its job and the handler did something the nonce never claimed
to permit.
The same applies to the object, one level down. A nonce binds an action name, not an object ID.
Change the order_id in a form and the nonce still verifies, because
order_id was never in the hash. Whether the caller may edit that order is a question
for current_user_can( 'edit_shop_order', $order_id ), and it survives no matter how
the request was authenticated.
A plugin has a settings screen that renders a form with wp_nonce_field(
'acme_save' ) and posts to admin-post.php with an
acme_save_settings action. The handler verifies the nonce, sanitises every field,
writes the option, and redirects back. Users report the expired-link page after leaving the
screen open for a day, and one consultant says the nonce makes the capability check unnecessary
because only users who can reach the screen get the nonce.
Fix the handler so a stale nonce and an unauthorised user produce two different, readable outcomes, and say which check runs first and why the order carries information rather than being a style choice.
Then write the one-paragraph answer to the consultant, in terms of what is in the hash.
Finally, describe the test you would run to decide whether the nonce on that screen is being served out of a page cache, and say what you would change if it is.
Show the solution and the reasoning
// Fragment of a fictional plugin. Not runnable on its own.
add_action( 'admin_post_acme_save_settings', 'acme_handle_admin_save' );
function acme_handle_admin_save() {
// 1. Intent. Same action string as wp_nonce_field() on the screen.
// $stop = false so this handler decides what the user sees.
if ( ! check_admin_referer( 'acme_save', '_wpnonce' ) ) {
wp_die( 'That form expired. Reload the settings screen and save again.', 403 );
}
// 2. Permission. Separate question, separate check.
if ( ! current_user_can( 'acme_manage_settings' ) ) {
wp_die( 'Your account cannot change these settings.', 403 );
}
// 3. Data.
$mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
if ( ! in_array( $mode, array( 'live', 'test' ), true ) ) {
wp_die( 'Unknown mode.', 400 );
}
update_option( 'acme_mode', $mode );
wp_safe_redirect( add_query_arg( 'updated', 'true', admin_url( 'options-general.php?page=acme' ) ) );
exit;
}
The order answers three different questions. The nonce establishes that the request came from a page this site rendered for this session, so a cross-site forgery is refused before any of your logic runs. The capability check establishes that the session holder may make the change, which the nonce never claimed to know. Sanitisation establishes that the values have the shape the option expects. Swapping the first two would still be safe here, but it makes the failure messages wrong: an unauthorised user with a valid nonce would be told the form expired, hiding a permission problem behind a plumbing problem.
Note what check_admin_referer() does not offer. It has no $stop
argument: it either returns the result or calls wp_nonce_ays() and stops, so the
message above is core's, not yours. For your own wording, or JSON, use
wp_verify_nonce( $_REQUEST['_wpnonce'] ?? '', 'acme_save' ) and handle
false yourself.
For the consultant, the argument is one sentence: the capability is not in the hash. The hash
covers the tick, the action string, the user ID and the session token, so a nonce proves the
request came from a page the site rendered for this session and proves nothing about what this
session may do. The screen's capability argument controls whether the menu item renders and
whether core loads the page; it does not run for a POST to admin-post.php, which is
a different request to a different file. The nonce is the guard on that second request and it
answers a different question, which is why the handler needs both checks.
The cache test is the one from the lesson. Cold-load the screen in a private window as one
account, save the HTML, flush the page cache, load the same URL as a second account or logged
out, and compare the _wpnonce values. Identical values mean the response is shared.
The fix is to exclude that URL from full-page caching, since it is an authenticated screen and
caching it is wrong for more reasons than the nonce, or to leave it cached and obtain a nonce
from an uncached request immediately before submitting. If the page is genuinely public, the third
answer is the honest one: stop pretending the nonce guards it, and design the endpoint around the
fact that anybody can call it.
Progress
Mark this lesson done and it stays marked in this browser.