WooCommerce Invalid Nonce Error With Full Page Cache

WooCommerce throwing an invalid nonce error on add to cart after your page cache expires? Here is why cached nonces break and the ESI fix that actually holds.
WooCommerce Invalid Nonce Error With Full Page Cache
WooCommerceCachingWordPress
May 20, 20266 min read1054 words

The Problem

I got a panicked message from a client last week: customers were seeing "Invalid nonce" on the product page when they clicked add to cart, and it only happened in the mornings. By the time the client tested it themselves, it had cleared up. A few hours later it came back.

The store runs WooCommerce 10.x with the new block-based product page and the Add to Cart with Options block, sitting behind LiteSpeed Cache. The error showed up in the browser console as a failed Store API request:

POST /wp-json/wc/store/v1/cart/add-item 403 (Forbidden)
{"code":"woocommerce_rest_invalid_nonce","message":"Nonce is invalid.","data":{"status":403}}

What made it confusing: the bug never appeared right after a cache purge. It surfaced roughly 12 to 24 hours later, then sometimes fixed itself, then returned. It hit every visitor on a given product page at once, not just one device. Variable products were worse than simple ones.

Why It Happens

WordPress nonces are not random tokens that live forever. A nonce is tied to a time window. By default the lifetime is 24 hours, split into two 12-hour ticks, and a nonce is valid for the current tick and the previous one. So a freshly minted nonce is good for somewhere between 12 and 24 hours depending on when in the tick it was generated.

Full page caching breaks this completely. When LiteSpeed (or WP Rocket, or a CDN) saves the rendered HTML of a product page, it saves the nonce baked into that HTML along with it. The WooCommerce blocks print the Store API nonce into the page as a data attribute the frontend reads on add to cart. The cached copy can sit there for the full cache TTL, which is often 24 hours or more.

Now the timeline lines up. The page was cached with a nonce that had, say, 6 hours of life left. Six hours later every visitor served that cached HTML gets a dead nonce, and the Store API rejects the add-to-cart request with a 403. Purge the cache and a fresh nonce gets baked in, so it works again until that one expires. That is the morning pattern, exactly.

The WordPress nonce documentation spells out the lifetime behaviour, and there is an open WooCommerce issue tracking this specifically for the Add to Cart with Options block. The takeaway is that a nonce and a cached page have fundamentally different expiry rules, and caching the nonce inside the page is the mistake.

The Fix

Do not extend the nonce lifetime to match the cache. That weakens CSRF protection on your cart for every visitor, and it only widens the window before the same bug returns. Fix it at the cache layer so the nonce stays fresh while the rest of the page stays cached.

If you run LiteSpeed Cache, use ESI. LiteSpeed can punch a hole in the cached HTML for the nonce using Edge Side Includes, so the page is cached but the nonce is resolved per request. Enable it under LiteSpeed Cache → Cache → ESI: turn on Enable ESI and Cache WooCommerce nonce via ESI. After that the nonce is served fresh even on a cached page, and the 403s stop.

If you run WP Rocket or a generic CDN, the cleanest fix is to refresh the nonce on the client before the request fires, from an endpoint that is never cached. Drop this into a small mu-plugin at wp-content/mu-plugins/fresh-store-nonce.php:

<?php
/**
 * Plugin Name: Fresh Store API Nonce
 * Serves a live nonce from an uncached endpoint so cached
 * pages never carry a stale Store API nonce.
 */

add_action('rest_api_init', function () {
    register_rest_route('site/v1', '/cart-nonce', [
        'methods'             => 'GET',
        'permission_callback' => '__return_true',
        'callback'            => function () {
            // Tell every cache layer to leave this response alone.
            nocache_headers();
            return [
                'nonce' => wp_create_nonce('wc_store_api'),
            ];
        },
    ]);
});

Then on the frontend, fetch a live nonce and use it on the Store API call instead of the one printed into the cached HTML:

async function freshNonce() {
  const res = await fetch('/wp-json/site/v1/cart-nonce', {
    headers: { 'Cache-Control': 'no-cache' },
  });
  const { nonce } = await res.json();
  return nonce;
}

export async function addToCart(productId, quantity = 1) {
  const nonce = await freshNonce();
  const res = await fetch('/wp-json/wc/store/v1/cart/add-item', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Nonce': nonce,
    },
    body: JSON.stringify({ id: productId, quantity }),
  });
  if (!res.ok) throw new Error(`Add to cart failed: ${res.status}`);
  return res.json();
}

The nocache_headers() call is the part that matters. It sets Cache-Control: no-cache plus the expiry headers WordPress uses, so WP Rocket and most CDNs skip the response and you always get a current nonce.

Whichever route you take, verify it. Purge the cache, wait until the cached page is older than 12 hours, then add to cart in an incognito window. If you get a 201 Created instead of a 403, the nonce is being resolved fresh and the fix is holding. You can also watch the response of /wp-json/site/v1/cart-nonce in the network tab and confirm it carries Cache-Control: no-cache.

The Lesson

Cached HTML and security nonces have different clocks, and baking one into the other is what produces the intermittent "Invalid nonce" 403 on add to cart. Solve it by keeping the page cached and the nonce live, either with LiteSpeed ESI or an uncached nonce endpoint. Bumping the nonce lifetime just hides the timer.

If your store goes quiet for a few hours every day and you cannot work out why, this is exactly the kind of caching-versus-nonce conflict I untangle on client sites. See my services. For a related caching issue on the cart UI itself, read WooCommerce mini cart not updating after AJAX.

Need this fixed on a live store without breaking checkout? Get in touch.

Back to blogStart a project