WordPress REST API CORS Error: Fix Cross-Origin wp-json Calls
If your headless frontend, mobile app, or a third-party JS widget calls your WordPress REST API and the browser console shows "blocked by CORS policy," your wp-json endpoint is probably working fine — the browser is the one refusing the response. This is a different animal from a REST API that's disabled outright, and it needs a different fix.
What this actually looks like
You won't see this in Postman, curl, or server logs, because CORS is a browser-side security rule, not a server error. It only shows up in the browser console, usually as one of these:
Access to fetch at 'https://yourdomain.com/wp-json/wp/v2/posts' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.Response to preflight request doesn't pass access control check: It does not have HTTP ok status.- Network tab shows the
OPTIONSrequest returning 403 or 404, and the actualGET/POSTnever fires. - Multiple
Access-Control-Allow-Originheaders in the response, which Chrome and Firefox both reject even if one of them is correct.
If you hit the same endpoint with curl and get a clean 200 with your JSON, that confirms it: WordPress is answering fine, the browser is the one saying no.
Why WordPress does this by default
WordPress core does not send permissive CORS headers on /wp-json/ by default. It's a deliberate choice — the REST API can expose post data, user info, and custom endpoints, so Automattic left cross-origin access opt-in rather than wide open. If your frontend lives on a different origin than your WordPress install (a Next.js app on Vercel, a Vue SPA on a subdomain, a mobile app calling your API, a Postman collection someone turned into a live integration), you're crossing an origin boundary and the browser enforces the same-origin policy unless the server explicitly says otherwise.
"Different origin" means protocol, domain, or port differs. https://app.example.com calling https://cms.example.com/wp-json/ is cross-origin even though both are "your" domains — subdomains count.
Fix 1: Send the right headers from WordPress
Don't reach for a "CORS plugin" first — most of them either whitelist everything (a real security problem if your REST API exposes anything sensitive) or fight with your caching layer. Add headers explicitly in functions.php of a child theme, or a small must-use plugin, using the rest_api_init hook:
add_action('rest_api_init', function () {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function ($value) {
$allowed_origins = [
'https://app.example.com',
'https://staging.example.com',
];
$origin = get_http_origin();
if ($origin && in_array($origin, $allowed_origins, true)) {
header('Access-Control-Allow-Origin: ' . esc_url_raw($origin));
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
header('Access-Control-Allow-Credentials: true');
}
return $value;
});
}, 15);
A few things that trip people up here:
- Never pair
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true. Browsers reject that combination outright — if you need cookies or auth headers sent, you must echo back a specific origin, not a wildcard. remove_filteron the defaultrest_send_cors_headersmatters. If you leave the core filter attached and add your own, you can end up with twoAccess-Control-Allow-Originheaders on the same response — which browsers treat as invalid and block anyway.- Whitelist origins in an array, not with string matching on a partial domain. `strpos($origin, 'example.com')` will happily match `evil-example.com.attacker.net`.
Fix 2: Handle the preflight OPTIONS request
Any request that isn't a simple GET, or that carries custom headers like Authorization or X-WP-Nonce, triggers a preflight: the browser sends an OPTIONS request first and only proceeds if that comes back with a 2xx and the right headers. WordPress's REST router handles OPTIONS automatically for registered routes, but two things commonly break it before it even reaches PHP:
ModSecurity or a WAF rule blocking OPTIONS
Some hosting-level security rulesets flag OPTIONS requests as suspicious and return a 403 before WordPress sees them. Check with:
curl -i -X OPTIONS https://yourdomain.com/wp-json/wp/v2/posts \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST"
If that comes back 403 with a ModSecurity signature in the body, you need the rule ID from your error log (in cPanel: Metrics → Errors, or ask your host) and a targeted exception for that route — not a blanket ModSecurity disable.
.htaccess rewrite rules eating OPTIONS before WordPress loads
If someone's added strict method restrictions in .htaccess — often left over from a "harden your site" checklist — OPTIONS can get 405'd at the Apache layer. Look for a block like:
<LimitExcept GET POST>
Deny from all
</LimitExcept>
That silently kills every preflight. Either remove it or add OPTIONS to the allowed list.
Fix 3: Watch for a second set of CORS headers
This is the one that wastes the most debugging time. If you're behind Cloudflare, using a caching plugin (LiteSpeed Cache, WP Rocket) with an "add security headers" toggle, or your Nginx/Apache vhost already injects Access-Control-Allow-Origin, you can end up with the header set twice — once by the server layer, once by WordPress. The browser sees two values and rejects both. Check the actual response headers, not just what your code sends:
curl -sI https://yourdomain.com/wp-json/ | grep -i access-control
If you see the header appear more than once, remove it from whichever layer isn't the source of truth. Usually that means deleting an Header set Access-Control-Allow-Origin line from .htaccess or a duplicate Cloudflare Transform Rule, and letting the PHP-level filter above be the only place it's set.
Fix 4: Nonces and cookies across origins
If your headless frontend authenticates with the logged-in WordPress cookie and a nonce (rather than Application Passwords or JWT), be aware that cookies don't travel cross-domain by default either. You'll need credentials: 'include' on the fetch call, Access-Control-Allow-Credentials: true on the response, and — if the frontend is on a different registrable domain, not just a subdomain — SameSite=None; Secure on the cookie itself. For anything beyond a same-site subdomain setup, switching to Application Passwords or a JWT plugin is usually less fragile than fighting cookie SameSite rules.
Prevention
| Situation | What to do up front |
|---|---|
| Building a new headless frontend | Decide your allowed origins list before launch and hardcode it — don't wildcard "just to get it working," you'll forget to lock it down later. |
| Using a caching plugin or CDN | Check if it injects security headers, and make sure it's not also touching Access-Control-Allow-Origin. |
| Rotating frontend domains (staging → prod) | Keep the origin whitelist in one place (a filter or constant), not scattered across multiple plugins. |
| Adding a mobile app or third-party integration | Prefer Application Passwords over cookie-based auth — it sidesteps SameSite cookie issues entirely. |
Quick diagnostic checklist
- Confirm the endpoint works outside a browser:
curl -i https://yourdomain.com/wp-json/ - Open DevTools → Network, reproduce the call, and read the exact console error — it tells you whether it's missing headers, a preflight failure, or a duplicate header.
- Test the OPTIONS preflight directly with curl, as shown above, to rule out a WAF or
.htaccessblock. - Check for duplicate
Access-Control-Allow-Originheaders withcurl -sI ... | grep -i access-control. - If cookies are involved, verify
SameSiteandSecureattributes match your cross-origin setup.
Frequently asked questions
Why does curl work but the browser blocks the same request?
CORS is enforced entirely by the browser, not the server. curl and Postman never apply the same-origin policy, so they'll happily show you a clean 200 response even when a browser would refuse to expose that response to your frontend JavaScript. Always test cross-origin issues in DevTools' Network tab, not just curl.
Can I just set Access-Control-Allow-Origin to * and move on?
You can, but only if the endpoint doesn't need cookies or auth headers and doesn't expose anything sensitive. A wildcard origin can't be combined with Access-Control-Allow-Credentials: true, and it means any site on the internet can call your REST API from a browser. For anything beyond public read-only content, whitelist specific origins instead.
Do I need a CORS plugin, or is functions.php enough?
A small snippet in functions.php or a must-use plugin is usually enough and gives you full control over which origins are allowed. Most 'CORS enabler' plugins either wildcard everything or add their own header on top of what WordPress core already sends, which is how you end up with duplicate Access-Control-Allow-Origin headers.
Why does the OPTIONS preflight request fail with a 403 even though GET works fine?
That's almost always a WAF or ModSecurity rule flagging the OPTIONS method, or a restrictive .htaccess LimitExcept block, rejecting the request before it reaches WordPress. Test the OPTIONS request directly with curl to confirm, then get the specific rule ID from your error log and add a targeted exception.
I fixed the headers but the browser still blocks it — what's left?
Check for a duplicate Access-Control-Allow-Origin header coming from Cloudflare, your caching plugin, or an Nginx/Apache directive layered on top of WordPress's own header. Browsers reject a response with more than one value for that header, even if one of them is correct.