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 exactlywp_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-Nonceheader or_wpnoncequery 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, not403 - 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.

