The Problem
I ran into this on a headless build last week. The frontend is Next.js on Vercel, the backend is WooCommerce on a managed host, and the cart is supposed to talk to the Store API at /wp-json/wc/store/v1/cart. Local dev worked. The staging Vercel preview worked. Production failed instantly with the browser console showing:
Access to fetch at 'https://shop.example.com/wp-json/wc/store/v1/cart'
from origin 'https://www.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
The GET /cart request never even left the browser. The OPTIONS preflight was returning a 200 with no CORS headers, so the browser blocked the real request. Adding items to the cart from the headless storefront silently failed. Checkout could not initialize because the cart token was never minted.
The frustrating part: a curl from my terminal with -H "Origin: https://www.example.com" returned the cart JSON just fine. The headers showed up. The browser was the only client refusing the response.
Why It Happens
The WooCommerce Store API ships with permissive CORS by default for same-origin and a small allowlist, but it does not send Access-Control-Allow-Origin for cross-origin browser requests unless the request carries a valid Nonce or Cart-Token header. The preflight OPTIONS request never includes either header — that is the whole point of a preflight — so the server treats it as anonymous and returns no CORS headers.
Three things compound this on a headless setup:
- The
Nonceheader. Logged-in cart requests use a WordPress nonce. The nonce is bound to the WP session cookie, which the browser will not send cross-origin withoutcredentials: 'include'and a matching CORS allow list. If your frontend is onwww.example.comand WordPress is onshop.example.com, the cookie scope and the Store API's nonce check both fail. - The
Cart-Tokenheader. Guest cart requests use a JWT-style cart token that the Store API mints on the first request and returns in theCart-Tokenresponse header. The browser cannot read that header unless the server explicitly exposes it withAccess-Control-Expose-Headers. Without it, your fetch wrapper never sees the token and every subsequent request behaves like a brand new guest. - Hosting-level OPTIONS handling. Cloudflare, WP Engine, and Kinsta all intercept
OPTIONSrequests at the edge for their own reasons (firewall rules, page rules, caching). When the edge answers the preflight before WordPress sees it, none of the Store API CORS logic runs.
The Store API authentication docs cover the cart token flow, but the CORS preflight interaction is not called out anywhere obvious.
The Fix
Three layers: WordPress side to send the right CORS headers, frontend side to forward the cart token, and edge side to let the preflight reach PHP.
Step 1: Send CORS headers from WordPress on Store API responses. Drop this into a small mu-plugin at wp-content/mu-plugins/store-api-cors.php:
<?php
/**
* Plugin Name: Store API CORS for Headless
*/
add_action('rest_api_init', function () {
$allowed = ['https://www.example.com', 'https://staging.example.com'];
add_filter('rest_pre_serve_request', function ($served, $result, $request) use ($allowed) {
$route = $request->get_route();
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (str_starts_with($route, '/wc/store') && in_array($origin, $allowed, true)) {
header("Access-Control-Allow-Origin: {$origin}");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Nonce, Cart-Token, X-WC-Store-API-Nonce');
header('Access-Control-Expose-Headers: Nonce, Cart-Token, X-WC-Store-API-Nonce, Link');
header('Vary: Origin');
}
return $served;
}, 10, 3);
}, 15);
add_action('init', function () {
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS'
&& str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/wp-json/wc/store')) {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed = ['https://www.example.com', 'https://staging.example.com'];
if (in_array($origin, $allowed, true)) {
header("Access-Control-Allow-Origin: {$origin}");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Nonce, Cart-Token, X-WC-Store-API-Nonce');
header('Access-Control-Max-Age: 600');
}
http_response_code(204);
exit;
}
}, 1);
Two filters because the Store API short-circuits some preflights before rest_pre_serve_request fires. The early init handler answers OPTIONS directly with the right headers. The rest_pre_serve_request filter handles the actual GET/POST responses. Note the explicit allow list — never echo back $_SERVER['HTTP_ORIGIN'] without validating it.
Step 2: Persist the Cart-Token on the frontend. The Store API mints the cart token on the first request. Read it from the response and send it back on every following request. In a Next.js client component:
const TOKEN_KEY = 'wc_cart_token';
export async function storeApi(path: string, init: RequestInit = {}) {
const token = typeof window !== 'undefined'
? localStorage.getItem(TOKEN_KEY)
: null;
const res = await fetch(`https://shop.example.com/wp-json/wc/store/v1${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Cart-Token': token } : {}),
...init.headers,
},
});
const fresh = res.headers.get('Cart-Token');
if (fresh && typeof window !== 'undefined') {
localStorage.setItem(TOKEN_KEY, fresh);
}
if (!res.ok) throw new Error(`Store API ${res.status}`);
return res.json();
}
The credentials: 'include' is required for any cookie-bound nonce flow. Without Access-Control-Allow-Credentials: true on the server, the browser will throw credentials errors before the request even fires.
Step 3: Stop the edge from swallowing OPTIONS. On Cloudflare, add a page rule for *example.com/wp-json/wc/store/* with Cache Level: Bypass and Disable Performance. On WP Engine, open a support ticket asking them to disable the platform-level OPTIONS handler for /wp-json/* — they will do it. On Kinsta, the same request goes to the MyKinsta team.
Step 4: Verify the preflight from the command line. From outside the browser:
curl -i -X OPTIONS \
-H "Origin: https://www.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,cart-token" \
https://shop.example.com/wp-json/wc/store/v1/cart/add-item
You want a 204 No Content with Access-Control-Allow-Origin: https://www.example.com, Access-Control-Allow-Credentials: true, and Access-Control-Allow-Headers listing cart-token. If you see a 200 with no CORS headers, the edge is still intercepting and PHP never ran.
The Lesson
Headless WooCommerce needs three things lined up: the Store API sending CORS headers (with Cart-Token in both Allow-Headers and Expose-Headers), the frontend persisting and replaying the token, and the edge layer letting the OPTIONS request reach PHP. Skip any one and the browser quietly blocks the cart.
If your headless cart works in dev but breaks the moment it hits production behind a CDN, that is the kind of cleanup I do. See my services. For a related Store API pitfall on the checkout side, see WooCommerce REST API returning 401 on WordPress 6.9.