You built a separate frontend — React, Next.js, Vue, whatever — and it's pulling content from your WordPress site through /wp-json/wp/v2/posts. Works fine when you hit the endpoint directly in the browser. Then you wire it into your app and the console lights up with Access to fetch at 'https://yoursite.com/wp-json/wp/v2/posts' from origin 'https://app.yoursite.com' has been blocked by CORS policy. The API is up, the data is there, but the browser refuses to hand it over.

This is one of the most common headaches we see from customers running a decoupled setup on SkyServer — WordPress as the backend, something else entirely as the frontend. Here's what's actually happening and how to fix it without opening your site up to every domain on the internet.

Symptom

You'll usually see one of these in the browser dev tools:

  • No 'Access-Control-Allow-Origin' header is present on the requested resource
  • CORS policy: Response to preflight request doesn't pass access control check
  • The OPTIONS preflight request returns 200 or 204 but with no CORS headers attached, so the actual GET/POST never fires
  • It works in Postman or curl (no CORS enforcement outside a browser) but fails in the browser — which throws people off, because it looks like the API is "broken" when it isn't

If you're calling the REST API from JavaScript running on a different origin than your WordPress install — different domain, different subdomain, or even a different port during local dev — CORS applies. Same-origin calls (your theme's own JS calling its own site) never hit this at all, which is why most WordPress installs never need to think about it.

Cause

By default, WordPress's REST API does not send Access-Control-Allow-Origin headers at all. It was never built assuming a cross-origin consumer. A few things commonly make it worse:

  • Security plugins stripping headers. Wordfence, iThemes Security, and similar plugins often disable or restrict the REST API by default, which either blocks the request outright (403) or strips headers a CORS plugin tried to add.
  • A caching layer or CDN eating the OPTIONS preflight. Cloudflare, LiteSpeed Cache, and Nginx FastCGI cache can cache or short-circuit the OPTIONS request before it reaches PHP, so your CORS headers never get attached to the response the browser actually sees.
  • Wildcard origin abuse. Some quick fixes set Access-Control-Allow-Origin: * — which breaks the moment you also need Access-Control-Allow-Credentials: true for cookie-based auth (application passwords over Basic Auth, or a logged-in session), because browsers reject a wildcard origin combined with credentials.
  • Duplicate or conflicting headers. If both a plugin and a manual functions.php snippet try to set CORS headers, PHP appends both, and the browser sees two values for the same header and rejects the response.

Fix

1. Confirm it's actually CORS and not a 403

Open the Network tab, find the failed request, and check the status code first. A 403 means a security plugin or server rule is blocking the REST API entirely — that's a permissions problem, not CORS, and no amount of header tweaking fixes it. Whitelist /wp-json/ in your security plugin's firewall rules before touching anything else.

2. Add CORS headers correctly via functions.php

The cleanest fix is hooking into rest_api_init and setting headers explicitly for the origins you actually trust — never a bare wildcard if you also need credentials:

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.yoursite.com',
            'http://localhost:3000',
        ];
        $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, OPTIONS, PUT, DELETE');
            header('Access-Control-Allow-Credentials: true');
            header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
        }
        return $value;
    });
}, 15);

Note the remove_filter call first — WordPress core already registers a default CORS handler on that same filter, and leaving it in place is exactly how you end up with duplicate headers.

3. Handle the OPTIONS preflight at the server, not just in PHP

If you're behind Nginx and using FastCGI cache, the preflight OPTIONS request can get cached with a stale (or missing) CORS header. Exclude it from caching explicitly:

location /wp-json/ {
    if ($request_method = OPTIONS) {
        add_header 'Access-Control-Allow-Origin' 'https://app.yoursite.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-WP-Nonce' always;
        return 204;
    }
    fastcgi_no_cache 1;
    fastcgi_cache_bypass 1;
    include fastcgi_params;
    fastcgi_pass 127.0.0.1:9000;
}

In cPanel hosting without direct Nginx access, add the equivalent to .htaccess above the WordPress rewrite block:

<IfModule mod_headers.c>
    SetEnvIf Origin "^https://app\.yoursite\.com$" CORS_ORIGIN=$0
    Header set Access-Control-Allow-Origin %{CORS_ORIGIN}e env=CORS_ORIGIN
    Header set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
</IfModule>

4. Purge Cloudflare and page cache after changing headers

If the site sits behind Cloudflare, purge the cache for /wp-json/* after any header change — Cloudflare will happily keep serving a cached response without your new headers until it expires or you clear it manually from the dashboard's Caching tab.

5. Test with curl to isolate browser vs. server

curl -i -X OPTIONS https://yoursite.com/wp-json/wp/v2/posts \
  -H "Origin: https://app.yoursite.com" \
  -H "Access-Control-Request-Method: GET"

Look for Access-Control-Allow-Origin in the response headers. If it's missing here, the problem is server-side and no frontend code change will fix it. If it's present here but the browser still blocks it, check for a second, conflicting header being added downstream (a CDN, a second plugin, or a proxy).

Prevention

  • Keep your allowed-origins list explicit and short — don't fall back to * "just to make it work," especially once authenticated requests are involved.
  • If you use application passwords for REST API auth, test both the login-protected and public endpoints separately; they can behave differently under a security plugin's rules.
  • Document the CORS snippet in version control (a small must-use plugin in mu-plugins/ is safer than editing the active theme's functions.php, since it survives a theme switch).
  • Re-test after every plugin update to a security or caching plugin — CORS handling is exactly the kind of thing that gets "helpfully" reset by a plugin's default settings during an update.

Frequently Asked Questions

Why does the REST API work fine in Postman but fail in the browser?

CORS is a browser-enforced policy, not a server-side restriction. Postman, curl, and server-to-server requests never send an Origin header the way a browser does, so they never trigger the check. If it works everywhere except the browser, it's CORS, not a broken endpoint.

Can I just set Access-Control-Allow-Origin: * and move on?

Only if your frontend never sends cookies or Authorization headers with credentials. The moment you need Access-Control-Allow-Credentials: true — for example, authenticated REST requests — browsers reject a wildcard origin outright. You need to echo back a specific, validated origin instead.

Do I need a plugin for this, or is a code snippet enough?

A short snippet in a must-use plugin is usually cleaner and easier to audit than a general-purpose "Enable CORS" plugin, which often sets broad, unreviewed defaults. Reserve a plugin for cases where you need per-endpoint CORS rules you don't want to maintain by hand.

My headers look correct in functions.php but the browser still blocks the request. What's wrong?

Check for a second source setting CORS headers — a caching plugin, a CDN edge rule, or leftover code from an earlier fix attempt. Duplicate Access-Control-Allow-Origin headers in one response cause the browser to reject the whole thing, even if each individual value would have been valid on its own.

Does this affect the regular WordPress admin or only the headless frontend?

Only cross-origin requests are affected. Your wp-admin dashboard, theme, and any same-origin AJAX calls keep working exactly as before — CORS only comes into play when JavaScript running on a different domain tries to call your /wp-json/ endpoints.