Jitsumi
Back to the blog

CORS Errors Explained: Fix Preflight, Headers and Credentials

A CORS error is not always an API failure. Learn to inspect preflight requests, configure allowed origins and debug cookies without disabling browser security.

A browser application and API server connected by glowing data lines across a translucent boundary.

Your API works in Postman. The same URL returns JSON through curl. But when your web app calls it, the browser says the request has been blocked by CORS. It is tempting to add Access-Control-Allow-Origin: * and move on. That can hide the actual problem, and it breaks as soon as cookies or other credentials enter the picture.

The short answer: a CORS error usually means the browser was not given permission to expose a cross-origin response to JavaScript, or its preliminary OPTIONS request did not pass the necessary checks. Check the *request origin*, identify whether a preflight occurred, and fix the *server response* at the responsible layer. Do not switch off browser protections. MDN's CORS guide explains the browser's checks; the Fetch standard defines the protocol.

First, understand what the browser is checking

An origin is the combination of scheme, hostname and port. Paths do not change it. These two addresses are different origins, even though they are on the same computer:

  • Frontend: http://localhost:5173
  • API: http://localhost:3001

Your JavaScript's fetch() from port 5173 to port 3001 is therefore cross-origin. When a server returns a suitable Access-Control-Allow-Origin header, the browser may expose that response to the requesting page. If the CORS check fails, JavaScript cannot read the response as it normally would.

CORS is not an authentication or firewall mechanism. The server can still receive requests from scripts, programs and other clients. A successful CORS check does not prove a user is signed in or authorised. Conversely, Postman and curl working says little about what the browser will allow JavaScript to read. The Express CORS middleware documentation explicitly makes this distinction.

There are two common paths:

  1. For certain cross-origin requests, the browser sends the real request and then checks whether its response may be shared.
  2. For requests requiring a preflight, the browser first sends an OPTIONS request describing the intended method and headers. Only if those checks pass does it proceed with the real request.

A POST request does not automatically mean a preflight. A JSON POST, however, commonly triggers one because application/json is not a CORS-safelisted request content type. A PUT, DELETE or request with an Authorization header can also trigger preflight. See MDN's preflight explanation .

What a preflight actually looks like

Suppose a frontend at http://localhost:5173 wants to send JSON to http://localhost:3001/api/echo.

Browser-to-API preflight sequence: OPTIONS, permission, POST and response.

Preflight sequence: the browser asks permission with OPTIONS before sending the JSON POST. If preflight fails, the intended POST is not sent.

Before the JSON request, the browser may send:

OPTIONS /api/echo HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

For an allowed frontend, a suitable preflight response could look like this (other headers are omitted):

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type
Vary: Origin

The browser can then send the POST. The POST response also needs the appropriate CORS response header; passing preflight does not grant permanent permission for every subsequent response. If a session cookie is involved, there are further credential checks, covered below.

Do not put `Access-Control-Allow-Origin` in your frontend request headers. It is a response header supplied by the API (or a trusted server layer in front of it). The browser sets Origin itself. The server decides which origins should be allowed.

Diagnose the failure before changing configuration

Open your browser's developer tools, switch to Network, reproduce the error and inspect the request to the API. Check its request URL, request method, Origin, response status and response headers. If you see an OPTIONS entry, inspect that as well. The Console usually has a more specific CORS reason than the JavaScript exception. MDN's CORS error reference explains why the browser often exposes only a generic network failure to application code.

Use this table to choose the next check:

Symptom

Likely place to investigate

Safer next action

Access-Control-Allow-Origin missing

API, reverse proxy, error response, or origin allowlist

Confirm the actual response adds one permitted origin for that route and environment.

Origin does not match

Wrong scheme, hostname, port or environment setting

Compare the browser's exact Origin with the configured allowed origins.

Preflight returns 401, 403 or 404

Authentication middleware or missing OPTIONS handling

Let a valid preflight reach CORS handling; authenticate and authorise the *real* endpoint.

Method or header not allowed

Access-Control-Allow-Methods or Access-Control-Allow-Headers

Permit the specific method and request headers your app actually requires.

Wildcard origin with credentials

Access-Control-Allow-Origin: * and credentials: 'include'

Return an explicitly permitted origin and Access-Control-Allow-Credentials: true, with proper session security.

Multiple Access-Control-Allow-Origin values

CORS configured in both app and proxy

Choose a single owner for the CORS policy; remove duplicate headers.

Works sometimes behind a CDN

Cached responses vary by request origin

Review Vary: Origin, caching rules and whether error/redirect responses carry the correct policy.

A genuine DNS, TLS, network, redirect or server failure may appear alongside a CORS message. Do not assume every message that mentions CORS is fixed by adding a header. The request may never have reached the intended handler.

A small Express example you can reproduce

This example uses Node.js, Express and the official `cors` middleware . It exposes a simple JSON API, allows two specific frontend origins and leaves cookie-based credential access *disabled* because the demo does not need login sessions.

Create an empty directory and install the packages:

npm init -y
npm install express cors

Create server.js:

const express = require('express');
const cors = require('cors');

const app = express();
const allowedOrigins = [
  'http://localhost:5173',
  'https://app.example.com', // Replace with your real frontend origin.
];

app.use(cors({
  origin: allowedOrigins,
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type'],
}));
app.use(express.json());

app.get('/api/ping', (req, res) => {
  res.json({ ok: true });
});

app.post('/api/echo', (req, res) => {
  res.json({ received: req.body });
});

app.listen(3001, () => {
  console.log('API listening at http://localhost:3001');
});

Run it with node server.js. Then, from a page actually served at http://localhost:5173, call:

const response = await fetch('http://localhost:3001/api/echo', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: 'hello' }),
});
console.log(await response.json());
// { received: { message: 'hello' } }

If you need a minimal page for testing, create an index.html file in another directory, start python3 -m http.server 5173 there, open http://localhost:5173, and run the fetch() in its browser console. Do not open the file through file://, because its origin may be treated differently. This example is a documented configuration, not a claim that Jitsumi measured it across browser versions.

There is no extra app.options() route here. With app.use(cors(...)), the middleware already handles preflight requests, as documented by Express. Keep this middleware ahead of routes or other middleware that might reject OPTIONS. In larger applications, use a narrower route-specific policy when only some endpoints need cross-origin access.

Check the preflight without a browser

From a terminal, send the same preflight headers:

curl -i -X OPTIONS 'http://localhost:3001/api/echo' \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type'

Look for an allowed origin, POST in the allowed methods, Content-Type in the allowed headers and a successful response status (typically 204 for this middleware). These are the meaningful parts of the response; other headers and ordering can vary.

Now change only the Origin value to http://localhost:5174 and repeat. Because that origin is not on the allowlist, the API should not grant it cross-origin read access. The server may still reply to a command-line client: `curl` does not enforce browser CORS rules. Use the browser to verify the end-to-end behaviour.

Cookies and login sessions change the rules

Imagine your frontend needs a session cookie when requesting account data. For a cross-origin fetch(), you typically need:

fetch('https://api.example.com/account', {
  credentials: 'include',
});

The API response must then use an explicit permitted origin, not *, and include:

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

If you configure Express's cors middleware for such an endpoint, set credentials: true only when required, and restrict origin to the legitimate client origins. Preflight requests themselves ordinarily do not include cookies, so do not demand an authenticated session before answering OPTIONS. The actual API request still needs real authentication and authorisation.

CORS settings cannot override cookie rules. On genuinely cross-site requests, SameSite=Lax or Strict cookies are not sent merely because the client sets credentials: 'include'. Cross-site cookies often require SameSite=None; Secure, and browsers' third-party cookie policies may still restrict them. Even a working credentialed CORS exchange does not protect against cross-site request forgery (CSRF). Keep appropriate anti-CSRF measures, secure cookie settings and server-side access checks. See MDN's Fetch credentials guidance .

The fixes that look convenient but are not fixes

`mode: 'no-cors'` does not make JSON from another API readable. It produces an opaque response in normal cross-origin cases: the browser hides the response body and useful headers. It is not a shortcut for APIs your JavaScript needs to consume.

Disabling browser security to make a local demo work changes the test environment rather than fixing the server. It also hides the very protections you need to handle correctly in production.

Reflecting every incoming `Origin` without validation amounts to allowing arbitrary websites to read permitted responses. This can be particularly dangerous with credentials or sensitive data. Do not confuse dynamic response headers with a reviewed origin allowlist.

Adding headers only to successful `200` responses leaves 404, 401, 500, redirects and proxy-generated errors as possible failure points. If the browser only reports a CORS failure, inspect the actual response path before trying another header. An authentication redirect to a different domain can also complicate preflight behaviour.

Letting both Nginx and the app add `Access-Control-Allow-Origin` can create duplicate values that browsers reject. Assign one layer responsibility, especially when development and production have different proxy paths. If responses are cached and the returned origin depends on the request's Origin, include and honour Vary: Origin. See MDN's Vary reference .

A five-minute CORS debugging checklist

  1. Write down both origins. Include the scheme, hostname and port. http://localhost:5173 is not the same origin as http://localhost:3001.
  2. Inspect the failed request in Network. Find the real method, headers, status, response headers and any OPTIONS request. Look for redirects and authentication failures.
  3. Reproduce the preflight with `curl` when one exists. Check the requested method and headers against what the server explicitly allows.
  4. Set a narrow server policy. Allow only intended frontend origins, methods and headers. If cookies are needed, handle credentials and CSRF deliberately.
  5. Retest in a normal browser. Verify both the preflight and actual response, then repeat in the deployed environment where proxies and caches may behave differently.

The useful question is not “Which header can I add to make this red console message go away?” It is “Which origin should be allowed to read which response, and which exact check failed?” Once that is clear, CORS stops being a guessing game and becomes a straightforward request-and-response problem.

Sources and further reading