CORS for WPGraphQL usually gets treated like a checkbox: “just add Access-Control-Allow-Origin and move on.” That’s how you end up with broken auth, failed preflights, or a GraphQL endpoint that quietly accepts requests from places it shouldn’t.

If you’re exposing /graphql from WordPress, CORS deserves a deliberate setup. WPGraphQL makes WordPress feel like an app backend, which means browsers start enforcing cross-origin rules in ways a normal PHP theme never had to care about.

I’ve had the best results by treating CORS for WPGraphQL as an endpoint-specific policy, not a whole-site header hack.

What CORS actually controls here

If your frontend runs on:

  • https://app.example.com

and WordPress with WPGraphQL runs on:

  • https://cms.example.com

then browser JavaScript making a fetch() to https://cms.example.com/graphql is a cross-origin request.

The browser will decide whether to allow the response to be visible to JavaScript based on CORS headers from the GraphQL server.

For WPGraphQL, the main headers you’ll care about are:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Max-Age
  • Access-Control-Expose-Headers
  • Vary: Origin

That last one gets missed constantly. If you dynamically allow some origins but not others, Vary: Origin matters for caches.

A quick reality check from a real API

Public APIs often use permissive CORS when they don’t rely on browser cookies. GitHub’s API is a good example. A real response from api.github.com includes:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That works because * is fine for public, non-credentialed requests.

For WPGraphQL, you often need authenticated requests from a browser. The second cookies or Authorization headers enter the picture, * stops being the right answer.

The two common WPGraphQL CORS scenarios

1. Public content only

If your GraphQL endpoint only serves public data and your frontend doesn’t send cookies or auth tokens, you can be more permissive.

2. Authenticated app frontend

If your frontend uses:

  • WordPress auth cookies
  • application passwords
  • JWT or bearer tokens
  • custom auth headers

then you need a stricter allowlist and probably Access-Control-Allow-Credentials: true.

That means no wildcard origin.

Where to set CORS in WordPress

You can do this in a plugin or functions.php, but I strongly prefer a small plugin. CORS is infrastructure behavior, not theme behavior.

Create a must-use plugin or normal plugin with endpoint-specific logic.

A practical WPGraphQL CORS plugin

This example only applies CORS to /graphql and handles preflight OPTIONS requests cleanly.

<?php
/**
 * Plugin Name: WPGraphQL CORS Policy
 * Description: Adds a strict CORS policy for the WPGraphQL endpoint.
 * Version: 1.0.0
 */

add_action('init', function () {
    $request_uri = $_SERVER['REQUEST_URI'] ?? '';
    $request_method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

    // Adjust if your GraphQL endpoint is different.
    if (strpos($request_uri, '/graphql') === false) {
        return;
    }

    $allowed_origins = [
        'https://app.example.com',
        'https://staging-app.example.com',
    ];

    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';

    if ($origin && in_array($origin, $allowed_origins, true)) {
        header('Access-Control-Allow-Origin: ' . $origin);
        header('Vary: Origin');
        header('Access-Control-Allow-Credentials: true');
        header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, Authorization, X-WP-Nonce');
        header('Access-Control-Max-Age: 600');

        // Optional: expose useful response headers to browser JS
        header('Access-Control-Expose-Headers: ETag, Link, X-Request-Id');
    }

    if ($request_method === 'OPTIONS') {
        status_header(204);
        exit;
    }
}, 0);

This is the baseline I’d ship first.

A few things I like about it:

  • It does not spray CORS headers across the whole WordPress site.
  • It only reflects known origins.
  • It supports credentials.
  • It handles preflight before WordPress does unnecessary work.

Why preflight fails so often with GraphQL

A lot of browser GraphQL clients send:

  • Content-Type: application/json
  • Authorization: Bearer ...

That usually triggers a preflight request:

OPTIONS /graphql
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

Your server must answer with matching allow headers, something like:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Vary: Origin

If Authorization is missing from Access-Control-Allow-Headers, the browser blocks the real request before it even reaches GraphQL.

That’s the part that confuses people: the backend might “work in Postman” while the browser still refuses to send the request.

Public WPGraphQL setup without credentials

If you truly have a public endpoint and want broad browser access, use a wildcard and skip credentials.

<?php
add_action('init', function () {
    $request_uri = $_SERVER['REQUEST_URI'] ?? '';
    $request_method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

    if (strpos($request_uri, '/graphql') === false) {
        return;
    }

    header('Access-Control-Allow-Origin: *');
    header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type');
    header('Access-Control-Max-Age: 600');
    header('Access-Control-Expose-Headers: ETag, Link');

    if ($request_method === 'OPTIONS') {
        status_header(204);
        exit;
    }
}, 0);

This is closer to the GitHub-style model: public API, broad access, no cookies.

Don’t mix this with Access-Control-Allow-Credentials: true. Browsers reject that combination.

If your frontend relies on WordPress login cookies, CORS alone won’t solve everything. Your frontend request also needs credentials enabled:

const response = await fetch('https://cms.example.com/graphql', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    query: `
      query Viewer {
        viewer {
          id
        }
      }
    `
  })
});

And the server must return:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Not *.

Also remember that cookie behavior is affected by cookie attributes like SameSite and Secure. People blame CORS when the real problem is cookie policy. Different issue, same symptom: “my authenticated request doesn’t work.”

Handling custom headers and WP nonces

If you use a custom frontend with WordPress-style nonce auth, include the nonce header in your allowlist.

header('Access-Control-Allow-Headers: Content-Type, Authorization, X-WP-Nonce');

If your app sends Apollo-specific or custom tracing headers, add those too. Be explicit. Don’t lazily allow every header unless you actually need to.

A reusable helper for cleaner code

If you want something less inline and easier to maintain:

<?php
function my_wpgraphql_allowed_origins(): array {
    return [
        'https://app.example.com',
        'https://staging-app.example.com',
    ];
}

function my_wpgraphql_is_request(): bool {
    $request_uri = $_SERVER['REQUEST_URI'] ?? '';
    return strpos($request_uri, '/graphql') !== false;
}

function my_wpgraphql_send_cors_headers(): void {
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';

    if (!$origin || !in_array($origin, my_wpgraphql_allowed_origins(), true)) {
        return;
    }

    header("Access-Control-Allow-Origin: {$origin}");
    header('Access-Control-Allow-Credentials: true');
    header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type, Authorization, X-WP-Nonce');
    header('Access-Control-Expose-Headers: ETag, Link, X-Request-Id');
    header('Access-Control-Max-Age: 600');
    header('Vary: Origin');
}

add_action('init', function () {
    if (!my_wpgraphql_is_request()) {
        return;
    }

    my_wpgraphql_send_cors_headers();

    if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
        status_header(204);
        exit;
    }
}, 0);

This is easier to test and less annoying to extend.

Common mistakes

Reflecting any origin blindly

This is the classic bad snippet:

header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);

Without an allowlist, you’ve effectively opened browser access to anyone. If credentials are enabled, that’s worse.

Forgetting Vary: Origin

If a CDN or reverse proxy caches the response for one origin, another origin can get the wrong CORS headers unless you vary on origin.

Sending headers too late

If output starts before headers are set, you’ll get partial or broken behavior. Hook early.

Enabling CORS site-wide

Your media library, admin pages, and random frontend routes probably do not need the same policy as /graphql.

Not handling OPTIONS

If the preflight gets a 404, 403, redirect, or HTML page, the browser blocks the real request.

Debugging a failing request

I usually check these in order:

  1. Is the browser sending an Origin header?
  2. Is there a preflight OPTIONS request?
  3. Does the preflight response include:
    • the exact allowed origin
    • allowed methods
    • allowed headers
    • credentials if needed
  4. Is Access-Control-Allow-Origin incorrectly set to * while using credentials?
  5. Is a cache or proxy stripping Vary: Origin?
  6. Are cookies blocked by SameSite or Secure rules instead of CORS?

Browser devtools will usually tell you exactly which CORS check failed, but you still need to know whether the bug lives in WordPress, your proxy, or your frontend fetch config.

Security posture I’d recommend

For most WPGraphQL installs, I’d use this policy:

  • allow only known frontend origins
  • allow only GET, POST, OPTIONS
  • allow only required request headers
  • enable credentials only if you truly need them
  • expose only response headers your frontend reads
  • send Vary: Origin
  • scope the policy only to /graphql

If you’re tightening broader web security around your WordPress app, CORS is only one piece. For headers like CSP, I’d look separately at [official browser and server docs], and if you want a practical CSP reference, https://csp-guide.com is useful.

WPGraphQL-specific final advice

WPGraphQL tends to push WordPress into headless territory, and headless setups punish vague CORS configs fast. My rule is simple: if the frontend origin is known, hardcode it. If auth is involved, never use *. If preflight exists, answer it explicitly.

That gets you out of the “why does GraphQL work in curl but not in Chrome?” loop, which is where too many WordPress teams lose half a day.