Help

Questions, Answered

Managed in WordPress (FAQ post type). Schema is generated for search engines automatically.

All posts
16 October 202615 min read

CORS Errors Explained: Fix Preflight, Credentials, and Allowed Origins the Right Way

Flat vector illustration of two browser windows on different origins linked by a request arrow through an orange checkpoint gate, beside a dark code panel of CORS response headers

“It works in Postman.” That was the first line of the message. A client had a headless storefront: the React front end lived on shop.example.com, the API on api.example.com. Adding to cart worked on staging, worked in Postman, worked on the developer’s laptop. In production, a few customers got a red error in the console and an empty cart. The error said something like “blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.”

It turned out to be two bugs wearing the same costume. The CDN in front of the API had cached a response that allowed the staging origin, because the server never sent Vary: Origin. And when a customer’s token expired, nginx returned a 401 without any CORS headers, so the browser reported a CORS error instead of “please log in again.” Nothing was wrong with the cart code at all.

CORS errors are some of the most searched errors in web development, and most of the fixes people paste from forums make things worse (Access-Control-Allow-Origin: * on everything, mode: "no-cors", a browser extension). In this guide I will walk through how CORS actually works, why preflight requests fail, how credentials change the rules, and the exact setups I use for Express, Next.js Route Handlers, the WordPress REST API, and nginx.

What CORS actually is (and what it is not)

Browsers enforce the same-origin policy. An origin is the scheme, host, and port together, so https://shop.example.com and https://api.example.com are different origins even though they share a domain. By default, JavaScript on one origin can send a request to another origin, but it is not allowed to read the response.

Cross-Origin Resource Sharing (CORS) is how the server tells the browser, “this other origin is allowed to read my responses.” It is a set of HTTP response headers, mainly:

  • Access-Control-Allow-Origin – which origin may read the response
  • Access-Control-Allow-Methods – which methods are allowed (used on preflight)
  • Access-Control-Allow-Headers – which request headers are allowed (used on preflight)
  • Access-Control-Allow-Credentials – whether cookies and HTTP auth are allowed
  • Access-Control-Expose-Headers – which response headers JavaScript may read
  • Access-Control-Max-Age – how long the browser may cache a preflight result

Two things follow from this, and they clear up most confusion:

  1. CORS is enforced by the browser, not the server. Postman, curl, and your backend do not care about CORS at all. “It works in Postman” tells you the API is up, not that CORS is configured.
  2. CORS does not protect your server. Setting an allowed origin only controls which web pages can read responses in a browser. Anyone can still call your API from a script. Authentication, authorization, CSRF protection, and rate limiting are separate jobs.

Read the console, then reproduce with curl

When a CORS check fails, JavaScript only gets a generic TypeError: Failed to fetch. For security reasons the browser does not hand your code the reason. The real explanation is in the DevTools console, and it is usually specific: missing header, wrong origin, wildcard with credentials, preflight did not return an ok status, and so on.

My debugging routine is always the same:

  1. Read the full console message, not just the first line.
  2. In the Network tab, look for an OPTIONS request right before the failing request. If it exists, the problem is often the preflight, not the real request.
  3. Reproduce the preflight with curl so I can see exactly what the server sends back:
curl -i -X OPTIONS https://api.example.com/cart \
  -H "Origin: https://shop.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type, authorization"

If the response is not a 2xx, or Access-Control-Allow-Origin does not echo https://shop.example.com, or Access-Control-Allow-Headers is missing authorization, you have found your bug without guessing.

Simple requests vs preflight requests

Not every cross-origin request gets a preflight. The browser sends some requests directly. The Fetch spec does not use the old name anymore, but most docs still call them “simple requests.” A request skips the preflight only when all of these are true:

  • The method is GET, HEAD, or POST.
  • You only set CORS-safelisted request headers: Accept, Accept-Language, Content-Language, Content-Type, and a single-range Range.
  • Content-Type, if set, is application/x-www-form-urlencoded, multipart/form-data, or text/plain.
  • No upload progress listeners on XMLHttpRequest and no ReadableStream body.

That means the most normal API call in a modern app triggers a preflight:

const res = await fetch("https://api.example.com/cart", {
  method: "POST",
  headers: {
    "Content-Type": "application/json", // not safelisted -> preflight
    Authorization: `Bearer ${token}`,    // custom header -> preflight
  },
  body: JSON.stringify({ sku: "TSHIRT-M", qty: 1 }),
});

Before sending that POST, the browser sends an OPTIONS request with three headers that describe what it wants to do:

OPTIONS /cart HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

The server has to answer that question correctly, and only then does the real request go out:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin
Diagram comparing a simple GET request that goes straight to the API with a JSON POST that first sends an OPTIONS preflight and waits for a 204 approval before the real request
Infographic 1: Simple request vs preflight – a GET with safelisted headers goes straight through; a JSON or Authorization request sends OPTIONS first and needs a 2xx with matching Allow headers.

Answering the preflight correctly

Most “preflight failed” errors come from one of these, in roughly this order of frequency on client projects I have inherited:

  • Auth middleware blocks OPTIONS. Preflight requests never include cookies or your Authorization header. If your auth layer returns 401 or redirects to /login for every unauthenticated request, it rejects the preflight before your CORS code runs. Let OPTIONS through before auth. This bites a lot of Next.js apps where a Proxy (Middleware) redirect runs on API paths.
  • The preflight gets a redirect. An http to https redirect, a missing trailing slash, or a www redirect on the API host. Not every browser follows redirects on preflighted requests, so call the final URL directly.
  • Non-2xx status. The preflight needs an ok status (200-299). A 404 because no route handles OPTIONS, or a 405 from a framework, fails the check. 204 No Content is the usual choice.
  • Allowed headers do not match. If the browser asks for authorization, content-type, idempotency-key and you only allow Content-Type, Authorization, the request is blocked. Every custom header your frontend sends must be listed.
  • Max-Age that does nothing. Access-Control-Max-Age defaults to 5 seconds. Browsers also cap it: Chromium at 7200 seconds (2 hours) and Firefox at 86400 seconds (24 hours). Setting 86400 is fine, just know Chrome will treat it as 7200.

If your API sees a lot of traffic, a sensible Max-Age is an easy win. Every preflight is an extra round trip before the real request, and on mobile networks that is noticeable.

Credentials: cookies, the wildcard, and SameSite

By default, fetch() uses credentials: "same-origin", so cookies are not sent on cross-origin requests. If your API uses cookie sessions, the frontend has to opt in:

const res = await fetch("https://api.example.com/me", {
  credentials: "include", // XHR / axios equivalent: withCredentials = true
});

The moment credentials are involved, the server rules get stricter:

  • The response must include Access-Control-Allow-Credentials: true, or the browser hides it from your code.
  • Access-Control-Allow-Origin must be the exact origin. * is rejected with credentials.
  • * in Access-Control-Allow-Headers, Access-Control-Allow-Methods, and Access-Control-Expose-Headers is treated as a literal name, not a wildcard, on credentialed requests. List things explicitly.
  • A Set-Cookie on a response whose Access-Control-Allow-Origin is * will not set the cookie.
  • The preflight itself never carries credentials, but its response must still say Access-Control-Allow-Credentials: true for the real request to go ahead.
Side-by-side comparison of a credentialed cookie request blocked when the server answers with a wildcard origin versus allowed when it answers with the exact origin and Allow-Credentials true
Infographic 2: Credentials and the wildcard – with cookies, Access-Control-Allow-Origin: * is blocked; the exact origin plus Access-Control-Allow-Credentials: true is allowed.

CORS also does not override cookie rules. A cookie only goes along if its own attributes and the browser’s cookie policy allow it. Here is the distinction that saves hours: shop.example.com to api.example.com is cross-origin but same-site, because they share the registrable domain example.com. A SameSite=Lax session cookie on the API is still sent with credentials: "include". If the frontend lives on a completely different domain, the cookie needs SameSite=None; Secure, and even then Safari and Firefox block or partition third-party cookies by default. For that setup I usually move to token auth in an Authorization header, or put the API behind the same site.

Allowlists, Vary: Origin, and the CDN trap

Access-Control-Allow-Origin takes exactly one value. You cannot list two origins in it, so the standard pattern is an allowlist on the server that echoes the request’s Origin when it matches:

const ALLOWED_ORIGINS = new Set([
  "https://shop.example.com",
  "https://staging.shop.example.com",
]);

function corsOrigin(origin) {
  return origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
}

Two rules I treat as non-negotiable:

  1. Compare exact strings, never loose patterns. A regex like /example\.com$/ also matches https://notexample.com. A check like origin.includes("example.com") matches https://example.com.attacker.dev. If you need subdomains, anchor the whole thing: /^https:\/\/([a-z0-9-]+\.)?example\.com$/. And never allowlist the string null; sandboxed iframes and file:// pages send Origin: null.
  2. Send Vary: Origin whenever the answer depends on the origin. This is what broke my client’s cart. Without it, a CDN or browser cache can store a response with Access-Control-Allow-Origin: https://staging.shop.example.com and serve it to production visitors. Their browser sees the wrong origin and blocks the response. Vary: Origin tells every cache to keep separate copies per origin.
Two CDN cache flows: without Vary Origin one cached response made for the staging origin is served to production visitors and blocked, with Vary Origin each origin gets its own cached copy
Infographic 3: The CDN trap – without Vary: Origin a response cached for one origin is served to another and blocked; with Vary: Origin each origin gets its own cached copy.

If a public endpoint truly serves anyone without credentials, Access-Control-Allow-Origin: * is fine and actually more cache-friendly, because the response is the same for everybody. Fonts on a CDN and a public read-only JSON feed are good examples. The trouble starts when * gets copied onto authenticated endpoints.

Errors that only look like CORS errors

A large share of “CORS errors” I debug are really server errors with missing headers. If your API returns a 401, 403, 404, or 500 without CORS headers, the browser cannot let your code read it, so you see a CORS message and never the real status.

On nginx this happens by default. add_header only applies to 200, 201, 204, 206, 301, 302, 303, 304, 307, and 308 responses unless you add the always parameter:

map $http_origin $cors_origin {
    default "";
    "https://shop.example.com"         $http_origin;
    "https://staging.shop.example.com" $http_origin;
}

server {
    location /api/ {
        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Vary                             "Origin" always;
        proxy_pass http://app_upstream;
    }
}

nginx skips a header whose value is empty, so an origin that is not in the map simply gets no Access-Control-Allow-Origin at all.

Two more nginx details that cause surprises. First, add_header directives are only inherited from the outer level if the current level defines none, so one extra add_header inside a location can silently remove all your server-level CORS headers. Second, decide where CORS lives. If nginx adds Access-Control-Allow-Origin and your app adds it too, the browser receives two values and rejects the response with a “multiple values” error. Pick one layer.

Expose the headers your frontend reads

Even when the request succeeds, response.headers.get("X-Total-Count") can return null. Cross-origin JavaScript can only read the CORS-safelisted response headers by default: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. Everything else, such as pagination counts, rate limit headers, or a request id, needs to be listed:

Access-Control-Expose-Headers: X-Total-Count, X-Request-Id

This one is sneaky because the Network tab shows the header. The browser received it; it just will not give it to your script.

Setup recipes I actually use

Express with the cors package

The cors middleware handles preflights for you (it responds with 204 by default) and adds Vary: Origin when it reflects an origin:

import express from "express";
import cors from "cors";

const ALLOWED_ORIGINS = new Set([
  "https://shop.example.com",
  "https://staging.shop.example.com",
]);

const app = express();

app.use(cors({
  origin(origin, callback) {
    // true reflects the request origin, false sends no CORS headers
    callback(null, Boolean(origin) && ALLOWED_ORIGINS.has(origin));
  },
  credentials: true,
  methods: ["GET", "POST", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization", "Idempotency-Key"],
  exposedHeaders: ["X-Total-Count", "X-Request-Id"],
  maxAge: 7200,
}));

Register it before your auth middleware so preflights never hit the login check. If you leave out allowedHeaders, the package reflects whatever the browser asks for, which is convenient but looser than I like on authenticated APIs.

Next.js Route Handlers

Route Handlers support an OPTIONS export. If you do not define one, Next.js answers OPTIONS automatically with an Allow header, but that automatic response does not include your CORS headers. For a cross-origin API route, I define it myself:

// app/api/cart/route.ts
const ALLOWED_ORIGINS = new Set([
  "https://shop.example.com",
  "https://staging.shop.example.com",
]);

function corsHeaders(origin: string | null): Headers {
  const headers = new Headers({ Vary: "Origin" });
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    headers.set("Access-Control-Allow-Origin", origin);
    headers.set("Access-Control-Allow-Credentials", "true");
  }
  return headers;
}

export async function OPTIONS(request: Request) {
  const headers = corsHeaders(request.headers.get("origin"));
  headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
  headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
  headers.set("Access-Control-Max-Age", "7200");
  return new Response(null, { status: 204, headers });
}

export async function POST(request: Request) {
  const item = await request.json();
  // validate, authorize, save...
  return Response.json({ ok: true, item }, { headers: corsHeaders(request.headers.get("origin")) });
}

The Next.js docs also point to Proxy (the Next.js 16 name for Middleware) or headers() in next.config.js when many routes need the same CORS headers. If you already redirect anonymous users in Proxy, make sure OPTIONS requests on your API paths are not redirected.

WordPress REST API

Here is something many WordPress developers do not know: core already sends CORS headers on REST responses. The rest_send_cors_headers() function is attached to the rest_pre_serve_request filter, and it echoes back whatever Origin the request sent, along with Access-Control-Allow-Credentials: true and Vary: Origin. Cookie-authenticated writes are still protected because they require a valid X-WP-Nonce, but if you want only your own front ends to read the API in a browser, replace the default with an allowlist:

add_action( 'rest_api_init', function () {
    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
    add_filter( 'rest_pre_serve_request', 'dp_rest_cors_allowlist' );
}, 15 );

function dp_rest_cors_allowlist( $value ) {
    $allowed = array(
        'https://app.example.com',
        'https://staging.app.example.com',
    );
    $origin = get_http_origin();

    header( 'Vary: Origin', false );
    if ( $origin && in_array( $origin, $allowed, true ) ) {
        header( 'Access-Control-Allow-Origin: ' . $origin );
        header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' );
        header( 'Access-Control-Allow-Credentials: true' );
    }
    return $value;
}

Priority 15 matters: core adds its filter inside rest_api_default_filters() on rest_api_init at priority 10, so removing it earlier does nothing. Be honest with yourself about what this buys you, though. Public endpoints like posts are still readable by any server or script; the allowlist only stops other websites from reading them in a visitor’s browser.

WordPress also gives you two filters for the header lists. By default it exposes X-WP-Total, X-WP-TotalPages, and Link, and it allows Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, and Content-Type. If your headless front end sends a custom header, add it:

add_filter( 'rest_allowed_cors_headers', function ( $headers ) {
    $headers[] = 'Idempotency-Key';
    return $headers;
} );

There is a matching rest_exposed_cors_headers filter for response headers your JavaScript needs to read.

Things that are not fixes

  • mode: "no-cors". This does not disable CORS. It gives you an opaque response with status 0 and no readable body or headers. Your code “stops erroring” because it can no longer see anything.
  • Setting Access-Control-Allow-Origin on the request. It is a response header. Adding it to your fetch() headers only adds a custom header, which triggers a preflight and makes things worse.
  • Browser extensions or disabled web security. They hide the problem on your machine only. Your users still get the error.
  • * everywhere. It fails the moment you add cookies, and on authenticated APIs it is the wrong mental model anyway.

During local development, a same-origin dev proxy (for example Vite’s server.proxy or a Next.js rewrite) is a perfectly good way to avoid CORS entirely. Just remember production still needs a real decision, because the proxy only exists on your laptop.

My CORS checklist

  1. Decide which layer owns CORS (app, framework, or nginx/CDN) and keep it in one place.
  2. Use an exact-match origin allowlist; echo the matching origin; never allow null.
  3. Send Vary: Origin on every response whose CORS headers depend on the origin.
  4. Answer OPTIONS with 204, before auth, with no redirects.
  5. List every custom request header in Access-Control-Allow-Headers.
  6. With cookies: credentials: "include" on the client, Access-Control-Allow-Credentials: true and an explicit origin on the server, correct SameSite on the cookie.
  7. Add CORS headers to error responses too (always on nginx), so a 401 looks like a 401.
  8. Expose the response headers your frontend actually reads.
  9. Set Access-Control-Max-Age (up to 7200 is what Chrome will honor).
  10. Test the preflight with curl before blaming the frontend.

Wrapping up

CORS feels random until you see the shape of it: the browser asks a question, the server answers with headers, and the browser decides whether your JavaScript gets to read the result. Almost every CORS error is the server answering the wrong question, answering too late (after auth), answering from a stale cache, or not answering at all on an error response.

My client’s cart bug took a two-line fix in the end: Vary: Origin on the API responses and always on the nginx headers. The expensive part was the day spent changing frontend code that was never broken. Next time you see “blocked by CORS policy,” run the curl preflight first. It will usually tell you exactly which header is missing.