CORS Explained: Preflight, Headers and Fixing CORS Errors

CORS (cross-origin resource sharing) is how a server tells the browser that JavaScript from another origin may read its responses. The server answers with Access-Control-Allow-Origin and related headers. Requests using other methods, custom headers or JSON bodies first send an OPTIONS preflight. The fix always belongs on the server you are calling, never the page.

The CORS response headers at a glance
HeaderWhat it allows
Access-Control-Allow-OriginWhich origin may read the response (one origin or *)
Access-Control-Allow-MethodsMethods allowed after a preflight
Access-Control-Allow-HeadersRequest headers allowed after a preflight
Access-Control-Allow-CredentialsCookies and auth sent with the request (value must be true)
Access-Control-Expose-HeadersResponse headers that script may read
Access-Control-Max-AgeSeconds a preflight answer may be cached

Updated

A CORS error looks like your code is broken, yet the request usually reached the server just fine. The browser simply refused to hand the answer to your script. The simulator below lets you describe a request and a server setup, then tells you whether a preflight fires, whether the browser lets the response through, and which console message you would see.

CORS request simulator

Describe a request and the server's CORS headers to see whether a preflight is sent and whether the browser lets your script read the response.

Common CORS errors and what fixes them

Message wording varies by browser. These are the reasons as Firefox names them on MDN; Chrome says the same things in longer sentences.

CORS console reasons and the server-side fix
Console reasonWhat it meansFix on the server
CORS header 'Access-Control-Allow-Origin' missingThe response carried no allow-origin header at allAdd the header to every response, including errors
CORS header 'Access-Control-Allow-Origin' does not matchThe header names a different originEcho the caller's origin from your allowlist
Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'Cookies were sent but the server answered with *Return the exact origin and Allow-Credentials: true
expected 'true' in CORS header 'Access-Control-Allow-Credentials'Credentials sent but the flag is absentSend Access-Control-Allow-Credentials: true
Did not find method in CORS header 'Access-Control-Allow-Methods'Preflight did not approve the methodList PUT, PATCH or DELETE in Allow-Methods
missing token in CORS header 'Access-Control-Allow-Headers' from CORS preflight channelA request header such as Authorization was not approvedList it in Allow-Headers
CORS preflight channel did not succeedOPTIONS failed, redirected or returned an errorAnswer OPTIONS with 204 and the headers
Multiple CORS header 'Access-Control-Allow-Origin' not allowedTwo layers (app and proxy) both added the headerSet it in one place only

Server snippets

Express: allowlist with credentials

Echoes the origin only when it is on the list, answers preflights with 204, and adds Vary so caches keep origins apart.

const ALLOWED = new Set(["https://app.example.com", "http://localhost:5173"]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "600");
    return res.sendStatus(204);
  }
  next();
});

Next.js route handler

A public, cookie-free endpoint can use the wildcard. Export OPTIONS so the preflight gets its own answer.

// app/api/data/route.js
const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

export function OPTIONS() {
  return new Response(null, { status: 204, headers: CORS });
}

export async function POST(request) {
  const body = await request.json();
  return Response.json({ received: body }, { headers: CORS });
}

nginx in front of an API

The always flag keeps the header on error responses too, so a 500 still shows its real status instead of a CORS error.

location /api/ {
  if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Origin "https://app.example.com";
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE";
    add_header Access-Control-Allow-Headers "Content-Type, Authorization";
    add_header Access-Control-Max-Age 600;
    return 204;
  }
  add_header Access-Control-Allow-Origin "https://app.example.com" always;
  proxy_pass http://127.0.0.1:3000;
}

The browser side: sending cookies cross-origin

credentials: "include" is what turns on the stricter credentials rules on the server.

const res = await fetch("https://api.example.com/me", {
  method: "GET",
  credentials: "include",
});
if (!res.ok) throw new Error("HTTP " + res.status);
const me = await res.json();

Same origin, and why the browser blocks reads

An origin is the scheme, host and port together. https://app.example.com and https://api.example.com are different origins, and so are http and https versions of one domain. By default a script may send a request to another origin but may not read what comes back.

CORS is the opt-in. The API lists which origins it trusts and the browser enforces that list on the visitor's behalf. That is why the header must come from the server you call.

Simple requests and preflights

A plain GET, or a POST from an ordinary form, goes straight out with an Origin header. The browser then checks the reply for an allowed origin before giving it to your code.

Anything beyond that first sends an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers. Only if the server approves both does the real request go out. Access-Control-Max-Age lets the browser remember the approval, though browsers cap how long.

Reading the console error

For security, your JavaScript only learns that the fetch failed, with a TypeError and no detail. The real reason appears in the browser console, and it names the header that was missing or wrong. Match it against the table of messages below.

How it works

  1. Set the page origin, the method, any custom header and whether cookies are sent.
  2. Set what the server answers: allowed origin, methods, headers and the credentials flag.
  3. Read the verdict: whether a preflight is needed, whether it passes, and the console reason if it fails.
  4. Copy the matching server snippet below and add it to the API, not to the front end.

Tips

Frequently asked questions

Why does Postman or curl work when the browser fails?

CORS is enforced only by browsers, to stop one site's scripts from reading another site's data with your cookies. Tools like curl are not running untrusted page code, so they ignore it. The server response was identical; only the browser decided to withhold it.

What triggers a preflight request?

Any method other than GET, HEAD or POST, any header outside the safelisted few (Accept, Accept-Language, Content-Language, Content-Type, Range), or a Content-Type other than form encoding, multipart or text/plain. Sending application/json is the most common trigger.

Can I fix CORS from the frontend?

No. Setting Access-Control headers on your request does nothing, and mode: no-cors gives you an opaque response your script cannot read. Either the API adds the headers, or your own server fetches the data and passes it on.

Can I use Access-Control-Allow-Origin: * with cookies?

No. When a request carries credentials, the browser rejects a wildcard origin and requires the exact origin plus Access-Control-Allow-Credentials: true. The same applies to wildcards in the methods, headers and expose headers lists.

Why can my script not read a custom response header?

Cross-origin script only sees a short list of safe response headers by default. List any others in Access-Control-Expose-Headers, for example X-Request-Id, and they become readable.

Related: HTTP status codes, Next.js API routes, Webhook vs API, OAuth explained, Website authentication guide.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap