A Nonce Proves Intent, and Never Permission to Act
WordPress nonces are short-lived CSRF tokens, not permission checks. Where they fail, why the failure looks like a logout, and what to pair them with.
Tomas Lindqvist · @tlindqvist
Hardening and operations
- Topic
- SEC
- Section
- Security & Hardening
- Published
- 03 AUG 2026
- Reading time
- 9 MIN
- Words
- 2208W
A support ticket arrives saying the site "logs people out at random". Usually it is the same
screen each time, often a settings form or a dashboard widget, and the person reporting it is
almost never wrong. They click save, the request comes back with a -1 or a 403, and
the browser lands on the login page because that is what wp_die() does when a nonce
fails. The session was never dropped.
The same shape appears in a second population: administrators on sites with an aggressive full-page cache. They get the failure on the first click after a cache flush, then it works, then it fails again the next day. Two different causes, one identical symptom.
I have audited a mix of plugin and theme code across WordPress 6.x on PHP 8.2 and 8.3, on
staging clones of production sites, and the recurring finding is not a missing nonce. It is a nonce
treated as the authorisation layer. Somebody wrapped the sensitive part of a handler in
wp_verify_nonce(), tested it as an administrator, watched it work, and shipped.
A nonce is a CSRF token with a misleading name
The name invites the wrong mental model. "Number used once" describes a cryptographic nonce, which is consumed by a single use. The WordPress function returns a short alphanumeric string, and it returns the same string for the same action for the whole life of the tick. Call it a CSRF token instead, because that is what it is: a value an attacker on another origin cannot produce, since producing it requires knowledge of a secret and, for authenticated requests, the user's session.
wp_create_nonce( 'myplugin_save' ) hashes the action string together with the
current user ID, the current session token and the current tick. wp_verify_nonce( $nonce,
'myplugin_save' ) rebuilds that hash and compares. Three consequences follow from the
inputs.
The user ID is in the hash, so a nonce created for one account does not verify for another. The session token is in the hash, so a nonce issued before the user logged in somewhere else no longer verifies, because that login replaced the token stored in the session and in the user's cookies. And the action string is in the hash, so a nonce minted for one form cannot be replayed against a different action, even by the same user in the same session.
// Minted for one action name, one user, one session, one tick.
$nonce = wp_create_nonce( 'myplugin_save_settings' );
// Verifies only when all four still match. Returns 1 or 2 on
// success, false on failure. It never returns a plain true.
$valid = wp_verify_nonce( $nonce, 'myplugin_save_settings' );
That return type trips people up. The function documents an integer or false, and
the integers encode which tick the nonce was minted in. A strict comparison against
true is a bug that fails closed, so it produces a handler nobody can call rather than
an open door, but it wastes an afternoon. Compare loosely, or better, do not compare at all and
let check_ajax_referer() handle it.
One more consequence falls out of the session token being an input. A nonce is not portable across accounts and it is not portable across a shared login. If two people use the same credentials on a site with concurrent sessions disabled, the second login can invalidate the first person's nonces. That is the mechanism, not a bug in your code.
The default lifetime is longer than you think
The tick comes from wp_nonce_tick(), which divides the current time by a lifespan
of 12 hours by default, filterable through nonce_life. Verification accepts a nonce
from the current tick or the previous one, which means a token minted at the very end of a tick
stays valid for almost the full second tick. The practical window is between 12 and 24 hours, and
its exact length depends on when in the tick the nonce was created.
Lifetime, not policy
That window is deliberately generous, because a shorter one would break forms that stay open. Reflecting WordPress 6.x behaviour: the tick length is a filter, not a constant you should redefine, and core offers no per-action lifetime. If a plugin needs a shorter window, it has to implement its own expiry on top and store the mint time itself.
The failure mode is a page that stays open longer than the window. An edit screen left in a
background tab overnight, an order form someone fills in across a shift change, a dashboard widget
that renders once and submits an hour later. The submission carries a nonce from two ticks ago,
verification fails, and the user is told they are not allowed to do the thing they just did. With
check_admin_referer() or a default check_ajax_referer(), the response is
a -1 and a stop, which in a browser looks like the session died.
If your users report random logouts on a form they took their time with, this is the first thing to check, and the fix is usually a handler that fails with an explanation rather than a page that looks like an authentication problem. For a form that genuinely should survive two ticks, mint the nonce at render time on an uncached request and submit promptly, or move the long-lived state server-side and stop relying on the browser's copy.
A nonce inside a cached page guards nobody
This is the failure that turns a working CSRF guard into decoration. Full-page caching, whether from a host, a plugin or a CDN, stores the rendered HTML of a page and serves it to the next visitor. If that HTML contains a nonce, the stored copy was generated for whoever triggered the cache fill, and everyone else is handed their token.
For a logged-out visitor the token is worth nothing, and the guard on the receiving side either accepts it in a broken way or rejects every submission. The worse version is an authenticated cache or a page that is cached without varying on the login cookie: the nonce belongs to a real account, it verifies, and it verifies for a stranger. The CSRF check passes because the token is valid; it is just not the caller's.
The check takes a minute. Load the page in a private window with the cache cold, save the HTML,
flush the cache, load it again as a different user, and diff the two name="_wpnonce"
values. Matching values mean the page is cached and a nonce is riding along inside it. The same
test applies to anything you render into a cached fragment: wp_localize_script()
output, a REST nonce in an inline script, a settings link with a token in the query string.
The fix is structural, not a smaller cache. Either exclude the page from full-page caching, or stop rendering the nonce into it and fetch one at submit time through an uncached admin-ajax or REST request immediately before the write. The second option keeps the page cacheable and it also removes the stale-nonce problem, because the token is minutes old rather than hours.
REST checks the header for you, and still asks for a callback
REST is the one place where this is handled automatically. When a request carries the
X-WP-Nonce header and cookie authentication is in play, the REST stack validates the
nonce during authentication. A cookie-authenticated request that carries a missing or invalid nonce
is rejected as a CSRF failure before your callback runs. A request with no nonce header at all is
different: the cookies are then ignored, so core treats the caller as logged out, and the route's
own permission check is what answers, often with the 401 that
rest_authorization_required_code() produces for anonymous callers.
Automatic nonce validation is not authorisation. It tells the stack that the cookies belong to
somebody and that the request came from a page the site rendered. It does not decide whether that
user may perform the operation. That decision belongs in permission_callback, and the
route needs it regardless of how the client authenticates. Note the scope: this applies to cookie
authentication. A request authenticated with an application password or another non-cookie scheme
is not exposed to CSRF in the same way and is not gated by the header.
For admin-ajax.php and admin-post.php nothing is automatic. The handler
does the work in a fixed order, and the order carries information: the nonce establishes that the
request came from a page this site rendered for this session, the capability check establishes that
the session holder may do this, and sanitisation establishes that the values are the shape you
expect. Each one answers a different question.
add_action( 'wp_ajax_myplugin_save_settings', 'myplugin_handle_save_settings' );
function myplugin_handle_save_settings() {
// 1. Intent. The third argument is $stop: false makes the function return
// false instead of dying with -1, so the handler can answer with JSON.
if ( ! check_ajax_referer( 'myplugin_save_settings', 'nonce', false ) ) {
wp_send_json_error( 'This request could not be verified. Reload the page.', 403 );
}
// 2. Permission. A logged-in subscriber passes step 1 and must fail here.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'You are not allowed to change these settings.', 403 );
}
// 3. Data. Sanitise each field to the type the option expects.
$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( 'myplugin_mode', $mode );
wp_send_json_success( array( 'mode' => $mode ) );
}
Delete step two from that handler and the nonce still passes for every logged-in account on the
site, including a subscriber who grabbed the action name out of the page source and posted to
admin-ajax.php directly. The nonce did its job. It proved the request was intended by
that session. It never claimed the session was allowed.
| Entry point | What the nonce proves | What is still missing |
|---|---|---|
admin-ajax.php | The request came from a page rendered for this user and session | A capability check, and sanitisation of every posted field |
admin-post.php | The same, tied to the action name in check_admin_referer() | A capability check before the write, and a safe redirect back |
REST with X-WP-Nonce | Cookie-authenticated request originated from the site, checked by the REST stack | permission_callback, including object-level checks |
| Admin screen form | The submission came from the rendered form for this session | The screen's capability, which the handler does not inherit |
Front-end nopriv handler | Nothing meaningful; there is no session to bind to | Everything: authentication or a deliberate decision that the action is public |
A short list of things that do not belong in the nonce's job description: rate limiting, ownership of the object being changed, the validity of the values being submitted, and whether the account still deserves its role. None of those inputs are in the hash, so none of them can be derived from it.
Choosing the guard per entry point, and the limit of the whole mechanism
The engineering decision is not whether to use a nonce. It is which guard is load-bearing at each entry point. For anything cookie-authenticated that changes state, use the nonce for intent and a capability check for permission, in that order, and never one without the other. For a public endpoint that anonymous visitors must reach, accept that the nonce contributes nothing and design around that: rate limiting, signature verification over the request body, or an explicit decision that the endpoint is safe to call by anyone.
That is the hard limit of the mechanism, and it is why the entry point matters more than the
token. For an anonymous visitor there is no session to bind the token to, so the value you would
hand out is derived from the action name and the tick alone, and every visitor gets the same
string. A wp_ajax_nopriv_ handler with a nonce check on it has the appearance of a
guard and the security properties of a comment.
The second limit is that a passing nonce says nothing about whether the request should have happened. A stolen session, a support account that kept its administrator role, a browser extension that reads the page and submits the form: all of these produce a valid nonce and a valid capability. Intent and permission can both be satisfied by someone who should not be there, and the only artefact left afterwards is on the other side of the log line. Log the user ID, the action and the object for anything that changes state, and treat the log as the part of the design that answers the question the nonce cannot.
What to do on Monday
- Grep for
wp_verify_nonceandcheck_ajax_referer, then read the ten lines after each call. If there is nocurrent_user_can()on the path to the write, that is the bug. - Load your three most-used authenticated screens in a private window with the page cache flushed, then again after a flush as a different user, and compare the nonce values.
- Find the forms that people leave open, and give the handler a readable failure instead of
wp_die()with a bare error code. - List every
wp_ajax_nopriv_action and decide, per action, what actually protects it, because the nonce does not. - Add one log line to each state-changing handler: user ID, action, object ID, outcome.
Tomas Lindqvist · @tlindqvist
Hardening and operations
Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.
Continue