Notes
Why the WooCommerce checkout nonce fails on a cached page
A WordPress nonce is not used once. It is valid for a window, and a cached checkout page carries one minted for somebody else. Your own test order passes because you are signed in.
6 min read
A customer tells you your checkout refused their order. The message mentioned an order that could not be processed, or an expired session. So you open the same page, place a test order, and it goes through. If your store sits behind a page cache, the likely culprit is the WooCommerce checkout nonce. And the reason your own test passed is that you were signed in.
What a nonce is, and what it is not
WordPress calls its security tokens nonces, and the name misleads. Because the documentation knows that, it says so plainly: they "help protect against several types of attacks including CSRF, but do not protect against replay attacks because they aren't checked for one-time use". So a nonce is not spent when somebody uses it.
Instead, it is a hash tied to an action, a user, a session and a slice of time. It holds good for the whole slice. In the same page's words, "During that time period, the same nonce will be generated for a given user in a given context". So the token shows a request came from a form your own site rendered. But it says nothing about who sent it, as the WordPress nonces documentation spells out: "Nonces should never be relied on for authentication or authorization, or for access control".
The WooCommerce checkout nonce has a name
At the classic checkout the field comes from wp_nonce_field( 'woocommerce-process_checkout', 'woocommerce-process-checkout-nonce' ) in the payment template. So the hidden input carries the name woocommerce-process-checkout-nonce, and the action string is woocommerce-process_checkout.
On submit, WC_Checkout::process_checkout() reads that field, falls back to _wpnonce, and passes the value to wp_verify_nonce(). Where the check fails and the basket still holds items, the shopper reads "We were unable to process your order, please try again." Where the basket is empty too, a session expiry message appears instead. Neither string mentions a cache, a token or a clock. Also, the submission goes through the classic checkout's own AJAX endpoint, so it draws no page load in your access log.
Twelve to twenty-four hours, not one use
Now the lifetime, because this is where the trouble starts. WordPress states that "By default, a nonce has a lifetime of one day", adjustable through a nonce_life filter measured in seconds. But no token actually gets a day. The system runs on ticks: "WordPress uses a system with two ticks (half of the lifetime) and validates nonces from the current tick and the last tick". Since ticks count from the Unix epoch rather than from a page load, "The actual lifetime is thus variable between 12 and 24 hours".
Verification also reports where in that window you landed. The reference for wp_verify_nonce() gives the return value as "1 if the nonce is valid and generated between 0-12 hours ago, 2 if the nonce is valid and generated between 12-24 hours ago". Therefore a 2 is a warning shot. The token passed, but it sits in its second tick, and the next boundary kills it. WordPress names the remedy too, which is "to refresh nonces that are in their second tick so that they do not expire". A WooCommerce checkout nonce inherits all of it.
Why a cached checkout page turns real customers away
Here is what makes cached HTML dangerous rather than merely stale. For logged-out visitors WordPress uses one user ID for everybody, since core "generates the same nonce for guests as they have the same user ID (value 0)". So one anonymous shopper's token verifies fine for the next, provided both fall inside the same tick.
Which is exactly why a cached checkout page seems to work at first. A cached page carries a WooCommerce checkout nonce minted for a stranger, hours ago. Then the tick rolls over. Every visitor served that copy posts a token from a dead window, verification returns false, and honest customers pile up against a page that looks entirely normal.
Meanwhile your own test order sails through. Page caches nearly always skip signed-in requests, so you get a freshly built page. Your token is minted seconds before you press the button, against your own user ID and session. Cannot reproduce.
What to keep out of the cache
WooCommerce names the pages to leave alone in its guidance on configuring caching plugins. Cart, My Account and Checkout, because "These pages need to stay dynamic since they display information specific to the current customer and their cart". Specifically, a WooCommerce checkout nonce is part of what dynamic means here.
Core tries to help by itself. WC_Cache_Helper::prevent_caching() defines DONOTCACHEPAGE and merges no-cache headers into the response. But it does that only where the request matches the page IDs set as cart, checkout and my account. So two gaps stay open. A checkout on a rogue copy or a landing page holding the shortcode falls outside it, which is one more reason to know every page in your store that can render a checkout form. Furthermore, a cache sitting in front of PHP never sees a PHP constant.
Raising nonce_life looks like a shortcut and is not one. Name the tradeoff honestly. A longer window keeps a stale cached page working for longer, and it keeps a leaked token usable for longer too. Excluding the checkout from full-page caching is the ordinary answer, and it costs cache hits on a page that was never the same for two people anyway.
The block checkout keeps a different clock
If your store runs the block checkout, this plays out differently. That checkout posts to the Store API, which carries its token in a request header named Nonce, alongside a Cart-Token. Moreover the Store API hands a replacement back every time: "After making a successful request, an updated Nonce header will be sent back--this needs to be stored and updated by the client to make subsequent requests". Since the client refreshes as it goes, a stale opening value rarely survives to payment. The note on where the block checkout actually posts covers that route.
Worth knowing: an expired reCAPTCHA token gives the same symptom on a far shorter clock, and it is a separate mechanism. That one is covered in the note on tokens expiring before verification. Before changing a setting, find out which of the two failed.
How to tell, from your own logs
A WooCommerce checkout nonce failure leaves a trace, once you decide to record one.
- Because the customer-facing message is generic, log the outcome instead, with a timestamp.
- Then compare those timestamps. Failures clustering at the same minute every twelve hours point at a tick boundary, not at an attack.
- Finally, check whether the failures all come from signed-out visitors, and ask your host what sits in front of PHP.
Where Checkout Bouncer fits
Checkout Bouncer is a free, GPL plugin on WordPress.org. It scores WooCommerce checkout submissions with Google reCAPTCHA v3, and it records the score, the verdict, the surface and the reason for each one, so you can trace a rejected order to a cause.
None of that repairs a WooCommerce checkout nonce. Verification fails inside WooCommerce long before scoring matters, and no plugin conjures a fresh token into HTML that a cache serves off disk. What a log gives you is a fast way to rule scoring out.
Its limits are the point. The plugin guards the WooCommerce checkout only, never login, registration, comments or contact forms. It uses reCAPTCHA v3 and nothing else, and it is neither a firewall nor a malware scanner. A Pro tier is planned and does not exist, so there is nothing to buy.