Fix 'Cookie nonce is invalid' (rest_cookie_invalid_nonce) in WordPress REST API

intermediate๐Ÿ“ WordPress2026-06-01| WordPress 5.0+, REST API, PHP 7.4+, Apache / Nginx

Error Message

{ "code": "rest_cookie_invalid_nonce", "message": "Cookie nonce is invalid", "data": { "status": 403 } }
#wordpress#rest-api#nonce#authentication

When this error shows up

You're calling the WordPress REST API from a theme script, a custom block, or a headless frontend โ€” and instead of data, you get a 403:

{ "code": "rest_cookie_invalid_nonce", "message": "Cookie nonce is invalid", "data": { "status": 403 } }

The user is logged in. Cookies are present. WordPress rejects the request anyway. The nonce sent with your API call didn't pass verification on the server.

Why it fails

WordPress REST API cookie authentication needs one specific nonce action: wp_rest. On every authenticated request, WordPress validates this token to confirm the call came from a real logged-in session โ€” not a forged cross-site request. Several things break that check:

  • Wrong nonce action โ€” the most common mistake. wp_create_nonce('anything_else') produces the wrong hash. The action must be exactly wp_rest.
  • Cached nonce โ€” a caching plugin served stale HTML with a nonce that expired hours ago. Nonces last 12โ€“24 hours; cached pages can serve them much longer.
  • Missing credentials flag โ€” no credentials: 'include' in the fetch call means the browser never sends cookies. The nonce check then fails regardless of whether the hash is correct.
  • Nonce not sent at all โ€” the X-WP-Nonce header or _wpnonce query parameter is absent from the request entirely.
  • Expired session โ€” the user's login timed out, which invalidates any nonces tied to that session.

Quick fix

Step 1 โ€” Use the correct nonce action

In functions.php or your plugin file, the action must be wp_rest โ€” not your plugin slug, not a custom string:

// Correct
$nonce = wp_create_nonce( 'wp_rest' );

// Wrong โ€” causes rest_cookie_invalid_nonce
$nonce = wp_create_nonce( 'my_plugin_action' );

Step 2 โ€” Inject the nonce into JavaScript with wp_localize_script

Hook into wp_enqueue_scripts to pass both the nonce and the REST root URL to your frontend:

function my_enqueue_scripts() {
    wp_enqueue_script(
        'my-app',
        get_template_directory_uri() . '/js/app.js',
        [],
        '1.0',
        true
    );

    wp_localize_script( 'my-app', 'wpApiSettings', [
        'root'  => esc_url_raw( rest_url() ),
        'nonce' => wp_create_nonce( 'wp_rest' ),
    ] );
}
add_action( 'wp_enqueue_scripts', 'my_enqueue_scripts' );

Step 3 โ€” Send the nonce in your fetch call

Two things matter here: the X-WP-Nonce header carries the token, and credentials: 'include' tells the browser to send auth cookies. Skip either one and you get a 403.

fetch( wpApiSettings.root + 'wp/v2/posts', {
    method: 'GET',
    credentials: 'include', // critical โ€” sends auth cookies
    headers: {
        'X-WP-Nonce': wpApiSettings.nonce,
        'Content-Type': 'application/json',
    },
} )
.then( res => res.json() )
.then( data => console.log( data ) );

Prefer query strings over headers? Append ?_wpnonce= instead:

const url = wpApiSettings.root + 'wp/v2/posts?_wpnonce=' + wpApiSettings.nonce;
fetch( url, { credentials: 'include' } );

Permanent fix โ€” handle nonce expiration gracefully

A nonce baked into the page at load time will expire. Single-page apps and long-lived sessions hit this constantly โ€” a user who leaves a tab open overnight comes back to a stale nonce. Register an AJAX endpoint that vends a fresh one on demand:

// functions.php
add_action( 'wp_ajax_wp_rest_nonce', function() {
    wp_die( wp_create_nonce( 'wp_rest' ) );
} );

Then wrap all your fetch calls in a helper that catches the expired-nonce 403 and retries automatically:

async function apiFetch( url, options = {} ) {
    const defaultHeaders = {
        'X-WP-Nonce': wpApiSettings.nonce,
        'Content-Type': 'application/json',
    };

    const response = await fetch( url, {
        credentials: 'include',
        ...options,
        headers: { ...defaultHeaders, ...( options.headers || {} ) },
    } );

    if ( response.status === 403 ) {
        const body = await response.json();
        if ( body.code === 'rest_cookie_invalid_nonce' ) {
            const refreshRes = await fetch(
                '/wp-admin/admin-ajax.php?action=wp_rest_nonce',
                { credentials: 'include' }
            );
            const newNonce = await refreshRes.text();
            wpApiSettings.nonce = newNonce;

            return fetch( url, {
                credentials: 'include',
                ...options,
                headers: { ...defaultHeaders, 'X-WP-Nonce': newNonce, ...( options.headers || {} ) },
            } );
        }
    }

    return response;
}

Replace every raw fetch() call with apiFetch() and nonce expiration stops being your problem.

Fix for caching plugins

WP Rocket, W3 Total Cache, LiteSpeed Cache โ€” they all cache the full page HTML, including whatever wp_localize_script injected. A visitor hitting a cached page gets a nonce that's potentially hours old and already dead. Three ways to deal with it:

  • Exclude the page from cache โ€” most plugins let you blocklist specific URLs, or skip caching for logged-in users entirely.
  • Use the nonce refresh pattern above โ€” fetch a fresh nonce before the first real API call instead of trusting the one baked into the page.
  • Shorten cache TTL โ€” cap it at 1โ€“2 hours on any page that makes authenticated REST API calls.

As a last resort, extend nonce lifetime server-side to buy more time between expirations:

add_filter( 'nonce_life', function() {
    return 24 * HOUR_IN_SECONDS; // 24 hours instead of the default 12
} );

That cuts down expiration failures โ€” but it also gives an attacker a longer window to replay a stolen nonce. Only reach for this if the other approaches aren't practical.

Verification

Open DevTools โ†’ Network tab, trigger the REST call, and inspect the request:

  • Response status is 200, not 403
  • Request headers include X-WP-Nonce: <hash>
  • Request headers include Cookie: wordpress_logged_in_... โ€” confirms auth cookies made it through

Quick console test to confirm everything works end-to-end:

fetch( wpApiSettings.root + 'wp/v2/users/me', {
    credentials: 'include',
    headers: { 'X-WP-Nonce': wpApiSettings.nonce },
} )
.then( r => r.json() )
.then( u => console.log( 'Logged in as:', u.name ) );
// Should print your username, not a 403

Tips

Cookie + nonce auth is same-origin only. It works when the browser and WordPress share a domain and the user has an active login session. Cross-origin frontends and server-to-server calls need something else โ€” use Application Passwords instead, built into WordPress since 5.6. Go to Users โ†’ Profile โ†’ Application Passwords, create one per integration, and authenticate with HTTP Basic:

curl -u "your_username:xxxx xxxx xxxx xxxx xxxx xxxx" \
  https://your-site.com/wp-json/wp/v2/posts

One password per integration โ€” revoke it the moment that integration is decommissioned. For generating a strong random credential for testing or a service account, toolcraft.app/en/tools/security/password-generator generates one locally in the browser with nothing sent to any server.

Related Error Notes