Skip to content 99% OFF 🎉 Anniversary Sale 99% OFF Shared Hosting Use Code HURRYUP Claim Offer 99% OFF Hosting
99% OFF Hosting — Code HURRYUP
Products
AI Website Builder New VPS Hosting Cloud Servers Web Hosting cPanel Hosting Dedicated Servers Domains
Company
About Documentation Support Center Contact Get Started Call +91 75795 45488
Login
Hosting Panel — cPanel & Billing Console Panel — VPS Management
ALL SYSTEMS OPERATIONAL
WordPress

WordPress REST API CORS Error: Fix Cross-Origin wp-json Calls

Getwebup 6 min read

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 OPTIONS request returning 403 or 404, and the actual GET/POST never fires.
  • Multiple Access-Control-Allow-Origin headers 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: * with Access-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_filter on the default rest_send_cors_headers matters. If you leave the core filter attached and add your own, you can end up with two Access-Control-Allow-Origin headers 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

SituationWhat to do up front
Building a new headless frontendDecide 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 CDNCheck 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 integrationPrefer Application Passwords over cookie-based auth — it sidesteps SameSite cookie issues entirely.

Quick diagnostic checklist

  1. Confirm the endpoint works outside a browser: curl -i https://yourdomain.com/wp-json/
  2. 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.
  3. Test the OPTIONS preflight directly with curl, as shown above, to rule out a WAF or .htaccess block.
  4. Check for duplicate Access-Control-Allow-Origin headers with curl -sI ... | grep -i access-control.
  5. If cookies are involved, verify SameSite and Secure attributes 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.

#wordpress #rest-api #cors #wp-json #headless-wordpress #troubleshooting

Keep reading

Chat with Support