SYSTEM DEBRIEF

JetMenu mega menu won’t open on cached pages: rest_cookie_invalid_nonce

Debrief: 9
A desktop mega menu stopped opening, and clearing the cache fixed it for about half a day at a time. The cause wasn't the plugin — it was a nonce baked into cached HTML, and WordPress core rejecting any REST request that carries a stale one.

The desktop mega menu on a client site stopped opening. No error on screen, nothing in the theme, nothing in a plugin update log. Just a nav that did nothing when you clicked it — on desktop only, and only sometimes.

If you landed here from a search box, this is the console line you’re looking for:

GET /wp-json/jet-menu-api/v2/get-elementor-template-content?id=…&signature=… 403 (Forbidden)
{"code":"rest_cookie_invalid_nonce","message":"Cookie check failed","data":{"status":403}}

Stack, as of September 2026: WordPress 7.1 on Kinsta, JetMenu 3.0.4, JetEngine 3.8.14.3, Gravity Forms 3.1.1, WP Rocket 3.23.3.3, Elementor Pro 4.2.3.

A note on pronouns: I say “we” in the debugging passages below. I was working alongside Claude Code in a live session, and we each caught things the other didn’t — including a couple of my own wrong turns.

What made it misleading

Three things pointed the wrong way.

First, clearing the cache fixed it — for about half a day. That makes “it’s a cache thing” feel like the whole answer, so you purge, watch it work, and move on. It comes back.

Second, it was desktop-only, which smells like CSS or a breakpoint. It isn’t. That menu is configured megaAjaxLoad: true, subEvent: "click", breakpoint: 767 — above 767px the panel fetches its contents over REST when you click. Below the breakpoint a different mobile script path runs and never makes the call. Same bug, one visible symptom.

Third, a second unrelated-looking error was sitting right underneath it:

Unable to verify nonce. Please refresh the page and try again.

I initially read that as a theme file, because the script is named scripts-theme.min.js. It’s Gravity Forms — gravityforms/assets/js/dist/scripts-theme.min.js. Same root cause, different consumer. Worth knowing, because that misread cost me a detour.

The investigation, in order

The whole thing turned on one test. We replayed the exact failing request three ways:

Request to the mega-menu endpointResult
X-WP-Nonce from a 17-hour-old cached page403 rest_cookie_invalid_nonce
X-WP-Nonce from a fresh, cache-busted fetch200 + the full Elementor template
No nonce header at all200 + the full Elementor template

That third row is the whole post. The endpoint is already public. It’s authorised by a stable signature parameter, and it answers happily with no nonce at all. It fails only because a nonce is present and stale. Sending nothing works. Sending something expired is fatal.

Here’s the line from the shipped plugin, jet-menu-public-scripts.js in JetMenu 3.0.4:

data: { id: t, dev: …devMode, signature: o },
beforeSend: function(e,t){ e.setRequestHeader("X-WP-Nonce", window.jetMenuPublicSettings.restNonce) }

It passes the signature that authorises the call, and then attaches a nonce it doesn’t need.

The root cause

WordPress prints a wp_rest nonce into the page HTML, at jetMenuPublicSettings.restNonce. That HTML gets cached at the edge and served to logged-out visitors for up to 24 hours (cache-control: public, max-age=0, s-maxage=86400).

Stock nonce_life is 86,400 seconds. But a nonce isn’t valid for 24 hours — wp_nonce_tick() divides that in half, so the tick is 12 hours and verification accepts the current tick and the one before it. A nonce created just before a boundary is valid for barely 12 hours. Minimum validity is half the cache TTL. The nonce reliably dies while the page carrying it is still being served.

And the failure isn’t JetMenu’s to fix, structurally. It’s rest_cookie_check_errors() in core: if a nonce is present and doesn’t verify, the request is rejected with 403 rest_cookie_invalid_nonce — regardless of whether the endpoint required authentication in the first place. We confirmed that against a stock core route, no plugins involved:

/wp-json/wp/v2/pages?per_page=1   fresh nonce -> 200
/wp-json/wp/v2/pages?per_page=1   stale nonce -> 403

So this isn’t a Crocoblock quirk. It’s a structural incompatibility between full-page caching and any front-end script that sends a cached nonce. JetMenu is just the one that made it visible. Crocoblock has an open issue for it — suggestions#4671, “Make Jet Menu cache-friendly,” open since January 2022.

Two things I got wrong

I broke my own rule twice here, and both are worth writing down because the corrections are the useful part.

I inferred a cause from arithmetic instead of measuring it. The 17-hour-old nonce should still have been valid under stock settings — it fell inside the previous tick. It wasn’t. So I concluded something on the site must be shortening nonce_life, and went looking for a security plugin doing it. Wrong. When we finally asked the server directly instead of reasoning about it:

effective nonce_life : 86400 sec (24 h)     # stock
verify <stale nonce> : false
verify <fresh nonce> : 1

Nothing was shortening it. The only nonce_life filter on the box was Gravity Forms, which lengthens its own submission nonces to three days. The real explanation was worse than my theory: the cached object was carrying a nonce older than its own Last-Modified header. Which means the practical lesson is you cannot use cache age to reason about nonce age — a rule I’d have missed entirely if I’d been right for the wrong reason.

My first fix was too narrow. I proposed a targeted patch that let JetMenu’s route through when only the nonce was stale. It would have worked — for JetMenu. Then we swept every nonce printed into the page and diffed the cached copy against a fresh render, on the theory that a nonce which changes between the two is a nonce that caching breaks:

NonceOwnerVerdict
restNonceJetMenu and JetEngineRotates — breaks
config_nonceGravity FormsRotates — breaks
logging_nonceGF Google AnalyticsRotates — breaks
nonceElementor Pro / GF GARotates — breaks
ajax_submission_nonceGravity FormsStable — cache-safe

Four families breaking, not one. JetEngine carries the identical restNonce, so every JetEngine REST feature was failing on the same schedule, silently. A JetMenu-shaped patch would have fixed the symptom I could see and left three I couldn’t.

That last row is the good news, and it’s the one I’d want to know first if this were my site: Gravity Forms form submissions were never affected. GF deliberately extends its submission nonce to three days, so it’s cache-stable by design. On a lead-generation site, that’s the difference between an annoying menu bug and a revenue incident. Only the config fetch was broken.

The fix

Three options were on the table:

  • Patch the JetMenu route. Too narrow, per above.
  • Shorten the edge cache TTL to fit inside nonce validity. This is managed Cloudflare in front of managed hosting — a support conversation, real origin-load cost, and it only shrinks the broken window instead of closing it.
  • Make the nonce outlive the cache. One filter, closes all four at once, no vendor dependency.

We went with the third, as a must-use plugin:

add_filter( 'nonce_life', function ( $life ) {
    if ( is_user_logged_in() ) {
        return $life;
    }

    return max( (int) $life, 3 * DAY_IN_SECONDS );
}, 9999 );

Three days gives a 36-hour minimum validity against a 24-hour maximum cache age — 12 hours of margin. Three details in there are load-bearing:

  • is_user_logged_in() — only logged-out visitors get the longer window, so the admin CSRF window stays at stock 24 hours. Logged-in users are never served cached HTML anyway (cache_logged_user = 0), so they don’t need it.
  • max() — this filter can only ever raise a lifetime. Returning a flat value would have clobbered the three-day window Gravity Forms sets for its own submissions, and broken the one thing that still worked.
  • 3 days — not arbitrary. It’s the value Gravity Forms already chose for exactly this problem.

The part that will bite you

Changing nonce_life changes the tick size, which instantly invalidates every anonymous nonce already sitting in cache. Deploy this and the menu breaks harder until you purge.

We watched it happen: deployed, tested, and the live site was serving the old nonce against the new tick — a clean 403. The order is not optional.

change  ->  purge  ->  warm

Same applies in reverse if you ever remove the plugin. Delete the file, then purge.

The monitoring gap, which is the real lesson

This site has a cache warmer and a monitoring board. Both were green the entire time the menu was dead.

Of course they were. The warmer fetches pages and records the HTTP status and whether the edge served a hit. Every row read 200 / HIT, correctly — the page was fine. The thing that was broken was a REST call the page makes afterwards, which no page-level check ever makes.

There’s also a trap in how you’d naively wire that up. A cold cache and a stale nonce are different faults with different remedies: a cold cache wants warming, a stale nonce wants purging. Warming does not refresh a nonce — a cache hit never re-renders the page, so PHP never runs and the nonce never changes. Fold the nonce check into the existing cold/warm canary and a stale nonce renders as “cold,” sending someone to warm the site, which cannot fix it.

So it got its own check and its own tile: fetch the page, extract the nonce actually being served, replay the real menu call, alert on anything but 200. Hourly, two HTTP requests, no credentials.

What I’d check first next time

  • Replay the failing request without the auth header. If it succeeds, the header is the bug, not the endpoint. That one test collapsed this whole investigation into a paragraph.
  • Diff a cached page against a fresh render and compare every nonce. Anything that changed is something caching will break. It’s a two-minute check that maps the entire blast radius.
  • Ask the server, don’t do the arithmetic. wp eval with wp_verify_nonce() and apply_filters('nonce_life', …) would have saved me an hour and a wrong hypothesis.
  • Assume cache metadata is lying about content age. age and Last-Modified describe the cache object, not necessarily what’s inside it.

And the general form, which is the bit worth carrying to sites that have never heard of JetMenu: any nonce printed into cacheable HTML is a bug waiting for the cache TTL to grow past half the nonce lifetime. It’s silent, it’s periodic, and every page-level monitor you own will tell you everything is fine.

Related: Cold cache after every purge: why your first visitor pays — same caching layer on the same site, and where the warmer this post leans on came from.


System Debriefs are drafted from working-session context by the harness that did the work, then reviewed and published by me.

Observed: Sep 9, 2026
Verified: Sep 9, 2026
Debrief: 9
Status: Current
Type: Investigation
Systems:
WordPress
Tech Stack:
Gravity Forms
JetEngine
Claude Code
WP-CLI
Kinsta
WP Rocket
JetMenu
Problem Domains:
Caching