WordPress Nonces and Why They Protect You

Somewhere in every WordPress request that changes something — publishing a post, deleting a comment, saving settings, deactivating a plugin — there's a short string of ten hex characters riding along, doing quiet, unglamorous security work. It's called a nonce, almost nobody thinks about it, and it's the main reason a random link in a spam email can't empty your media library while you're logged in.

Nonces are worth understanding even if you never write code, for three practical reasons: they explain a whole family of mysterious errors ('The link you followed has expired', 'Are you sure you want to do this?'), they're the thing that breaks in a specific, diagnosable way when caching is misconfigured, and they're a genuinely elegant piece of security design hiding in plain sight. Let's take it from the attack they exist to stop.

The attack: cross-site request forgery

Here's the uncomfortable fact at the root of it all: your browser attaches your cookies to every request it sends to your site, no matter which page triggered the request. When you're logged in to example.com and some entirely different website — a forum, an email, a malicious ad — causes your browser to send a request to example.com, that request arrives wearing your login cookie. As far as the server can tell, it's you.

So imagine WordPress had an endpoint like /wp-admin/post.php?action=delete&post=123 and checked only 'is this user logged in and allowed to delete posts?' An attacker doesn't need your password. They need you to be logged in (you usually are — WordPress sessions last two weeks) and to get your browser to fire that URL: an image tag pointing at it, a hidden auto-submitting form, a link that says 'You won't believe this'. Your browser dutifully sends the request, cookie attached, and post 123 is gone. That's cross-site request forgery — CSRF — and it's forging the request, not your credentials. The victim performs the attack themselves, unknowingly.

Notice what's missing from the attacker's toolkit: they never see your site's pages. They're firing requests blind from the outside. That asymmetry — legitimate users see the page before submitting it, attackers don't — is exactly the lever nonces pull.

The defense: a secret proof that you saw the page

A nonce is a token WordPress generates and embeds in its own pages — as a hidden form field or a URL parameter. When the action request comes back, WordPress checks: does this request carry a valid token? A legitimate user has one, because WordPress put it in the form they just loaded. The CSRF attacker doesn't, because the token is generated per-user, per-action, and they can't see your pages to steal yours. No valid nonce, no action — the request dies with that famous message: 'The link you followed has expired.'

What goes into the token matters. A WordPress nonce is a cryptographic hash mixing four ingredients:

  • The action — a name for what's being authorized, like delete-post_123. A nonce for deleting post 123 won't validate for deleting post 124, let alone for changing settings. Scoping means a leaked nonce authorizes one narrow thing, not everything.
  • Your user ID and session token — my nonce is useless to you. Tied to the specific logged-in session, so it dies when you log out.
  • A time window — nonces expire (more below).
  • Your site's secret keys — the NONCE_KEY and NONCE_SALT constants in wp-config.php. This is why fresh, unique salts matter: they're the secret ingredient that makes nonces unforgeable. An attacker who knows your salts can mint their own nonces, which is one reason 'rotate your salts after a compromise' is standard advice.

'Nonce' is a lie, and the lifetime is 12–24 hours

The name comes from cryptography, where a nonce is a number used once. WordPress nonces are not used once — the same nonce validates repeatedly within its time window. Purists grumble; the pragmatic view is that WordPress nonces are short-lived, scoped CSRF tokens, and the name stuck.

The lifetime mechanics explain a genuinely confusing behavior. WordPress divides time into 12-hour ticks and accepts nonces from the current tick and the previous one. Consequence: a nonce is valid for somewhere between 12 and 24 hours, depending on when in the tick it was generated. Not a fixed 24 — the window slides. This is why a form left open overnight sometimes submits fine and sometimes greets you with 'the link you followed has expired': it depends on which side of a tick boundary you loaded the page.

Why expire nonces at all? Damage limitation. If a nonce leaks — in a shared screenshot, a forwarded URL, a referrer header, a log file — it's a live capability to perform one action as you. Expiry turns a leaked token from a permanent skeleton key into a ticket that's stale by tomorrow.

The errors, decoded

Nonce failures produce a small family of messages worth recognizing on sight:

  • 'The link you followed has expired.' — the modern, friendlier wording. A nonce was missing, stale, or invalid. Usually: a page sat open past the validity window, or you logged out and back in (new session, old page still open with old nonces), or caching served you someone else's nonce (see below).
  • 'Are you sure you want to do this?' — the older, famously unhelpful wording, still shown in some code paths. Same meaning. WordPress isn't asking about your confidence; it's telling you a security check failed and it doesn't know why either.
  • 403 responses from admin-ajax.php or the REST API — the same check failing in background requests. Symptoms look weirder: buttons that spin forever, editors that won't save, 'Cookie check failed'. Same root cause, worth checking before blaming the plugin.

The fix for the innocent cases is almost always: reload the page (which mints fresh nonces) and retry. If reloading doesn't fix it, you're likely in the caching scenario, which deserves its own section.

The caching trap: how nonces break at scale

Here is the collision that generates real incidents. Page caching saves rendered HTML and serves it to everyone. Nonces are baked into rendered HTML and are per-user, time-limited. Cache a page containing a nonce, and every visitor for the next N hours receives the same nonce — generated for whoever warmed the cache, often a logged-out user (for whom nonces are minted with user ID 0).

The failure pattern: a form or AJAX-driven widget on a cached page works in testing (you were logged in, cache was cold), then fails intermittently in production — specifically for logged-in users, or for everyone once the cached copy's embedded nonce ages past 24 hours. Comment forms, add-to-cart buttons, voting widgets, and popup forms are the classic victims. The intermittency is the tell: it works after a cache purge, degrades over the following day, and nobody can reproduce it on demand.

The sound fixes, in order of preference:

  1. Fetch nonces out-of-band. The cached page carries no nonce; a small AJAX call fetches a fresh one after the page loads. Well-built plugins (WooCommerce among them) do a version of this. If a plugin's widget breaks on cached pages, check its docs for a 'cache compatibility' note before writing your own.
  2. Exclude genuinely dynamic pages from the page cache — cart, checkout, account pages. Every serious caching setup has an exclusion list; these belong on it.
  3. Match cache TTL to nonce lifetime. If nothing else, keeping page-cache lifetime under 10–12 hours prevents the aged-out-nonce variant, though it doesn't fix the cross-user variant. It's the duct-tape option — sometimes duct tape is what the afternoon allows.

The rule developers get wrong: nonces are not authorization

One sharp edge for anyone writing or reviewing plugin code, stated plainly: a nonce check answers 'did this request originate from a page I served to this user?' — it does not answer 'is this user allowed to do this?' Those are different questions, and code that checks a nonce but skips the capability check has a privilege-escalation bug: any logged-in user — a subscriber, a customer — who can obtain a page with the nonce on it can fire the action. Vulnerability databases are full of exactly this pattern.

Correct handling always pairs them: verify the nonce (check_admin_referer() or wp_verify_nonce()) and verify capability (current_user_can()). CSRF protection and authorization, both, every time state changes. If you're auditing a plugin and see a state-changing handler with one but not the other, you've found a finding.

Two related notes for completeness: the REST API does its nonce dance through the X-WP-Nonce header (action wp_rest) rather than form fields — same concept, different envelope. And forms for logged-out visitors get limited protection from nonces (user ID 0's nonce is the same for everyone logged out), which is why anti-spam on public forms relies on other tools; nonces were never the right instrument there.

What this means for you, by role

  • Site owner: when you hit 'the link you followed has expired', reload and retry before assuming breakage. Keep your wp-config salts unique and rotate them after any security scare — they're what make nonces unforgeable. And when a form on your site fails intermittently, say the words 'cached nonce' to whoever debugs it; you'll save them an hour.
  • Site builder: exclude dynamic pages from page caching, keep cache lifetimes sane, and prefer plugins that document cache compatibility for their interactive widgets.
  • Developer: nonce plus capability check on every state change, no exceptions; fetch nonces via AJAX on cacheable pages; never treat a nonce as proof of permission.

Nonces are a good example of security infrastructure working exactly as intended: invisible for years at a stretch, mildly annoying twice a year, and quietly standing between your logged-in session and every malicious link on the internet. Now that you know what the ten hex characters are doing, you'll spot both the errors and the design — and you'll know that 'Are you sure you want to do this?' was never really a question.

Photo: max laurell on Pexels