CORS Complete Reference

Cross-Origin Resource Sharing — headers, mechanics, patterns & troubleshooting

What is CORS?

Cross-Origin Resource Sharing (CORS) is a browser-enforced security mechanism that controls whether a web page at one origin (scheme + host + port) can request resources from a different origin.

Without CORS, the Same-Origin Policy (SOP) blocks cross-origin reads. CORS provides a controlled way for servers to opt in to cross-origin requests.

Why does it exist?

How it works (flow)

Browser (JS on site-a.com) Checks: same origin? No → CORS check Simple request? or Preflight first Server responds with CORS headers Browser allows or blocks

Key insight: The server always receives and processes the request. CORS only controls whether the browser lets JavaScript read the response.

Request Headers

These headers are sent by the browser during cross-origin requests.

HeaderSet byWhenPurpose
Origin Browser All cross-origin requests Identifies the requesting origin
Access-Control-Request-Method Browser Preflight only Which HTTP method the real request will use
Access-Control-Request-Headers Browser Preflight only Which custom headers the real request will include

Origin

The browser adds Origin to all cross-origin requests. You cannot override or remove it from JavaScript.

Origin: https://example.com

The server uses this value to decide whether to allow the request.

Access-Control-Request-Method

Sent during preflight requests only. Tells the server which HTTP method the upcoming real request will use.

Access-Control-Request-Method: PUT

Access-Control-Request-Headers

Sent during preflight requests. Comma-separated list of custom headers the real request will include.

Access-Control-Request-Headers: Content-Type, Authorization, X-Custom-Header

Response Headers

These headers are sent by the server to grant cross-origin access.

HeaderRequired?ScopePurpose
Access-Control-Allow-Origin Always Simple & Preflight Which origins may access the resource
Access-Control-Allow-Methods Preflight Preflight response Allowed HTTP methods
Access-Control-Allow-Headers Preflight Preflight response Allowed custom request headers
Access-Control-Allow-Credentials Optional Both Allow cookies/auth headers
Access-Control-Max-Age Optional Preflight response How long to cache preflight (seconds)
Access-Control-Expose-Headers Optional Simple response Which response headers JS can read

Access-Control-Allow-Origin

The essential CORS header. Must be present in the response or the browser blocks the response from JavaScript.

Wildcard (no credentials)
Access-Control-Allow-Origin: *
Specific origin
Access-Control-Allow-Origin: https://example.com

⚠ Cannot use * when Access-Control-Allow-Credentials: true — you must specify an exact origin.

Access-Control-Allow-Methods

Tells the browser which HTTP methods are permitted for cross-origin requests. Only used in preflight responses.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS

Access-Control-Allow-Headers

Which request headers the browser may use. Must mirror or include the headers from Access-Control-Request-Headers.

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

Access-Control-Allow-Credentials

When true, the browser includes cookies, TLS client certificates, and authorization headers in the cross-origin request.

Access-Control-Allow-Credentials: true

Restrictions when using credentials:

  • Access-Control-Allow-Origin must be an exact origin (not *)
  • Access-Control-Allow-Headers cannot be *
  • Access-Control-Expose-Headers cannot be *
  • fetch() must set credentials: 'include' or credentials: 'same-origin'

Access-Control-Max-Age

How long (in seconds) the browser should cache the preflight result. Reduces OPTIONS requests.

Access-Control-Max-Age: 86400

Browsers cap this: Chrome = 7200s (2h), Firefox = 86400s (24h).

Access-Control-Expose-Headers

By default, JavaScript can only read CORS-safelisted response headers. This header exposes additional ones.

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

Safelisted by default: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Pragma. All others require explicit exposure.

Simple vs Preflight Requests

Simple Request

Sent directly — no preflight check. Must satisfy all conditions:

  • Method: GET, HEAD, or POST
  • Only CORS-safelisted headers:
    Accept, Accept-Language, Content-Language, Content-Type (with restrictions)
  • Content-Type only:
    application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No event listeners on XMLHttpRequest.upload
  • No ReadableStream in request body
// Simple request — sent directly
fetch('https://api.example.com/data', {
  method: 'GET',
  headers: { 'Accept': 'application/json' }
})

Preflighted Request

Browser sends an OPTIONS request first. Triggered when any of these is true:

  • Method is not GET, HEAD, or POST
  • POST with non-simple Content-Type (e.g., application/json)
  • Custom headers (e.g., Authorization, X-Api-Key)
  • Uses ReadableStream
// Preflighted — OPTIONS sent first
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123'
  },
  body: JSON.stringify({ name: 'test' })
})

Preflight flow

1. Browser sends OPTIONS request with:
   Origin: https://frontend.com
   Access-Control-Request-Method: POST
   Access-Control-Request-Headers: Content-Type, Authorization

2. Server responds with:
   Access-Control-Allow-Origin: https://frontend.com
   Access-Control-Allow-Methods: POST, GET, OPTIONS
   Access-Control-Allow-Headers: Content-Type, Authorization
   Access-Control-Max-Age: 3600

3. If preflight passes → browser sends the real request
4. Server responds with data + Access-Control-Allow-Origin
5. Browser exposes response to JavaScript

Common methods that trigger preflight

MethodPreflight?Why
GETNo*Simple method (unless custom headers)
POST (form)No*Simple Content-Type
POST (JSON)Yesapplication/json is not simple
PUTYesNot a simple method
PATCHYesNot a simple method
DELETEYesNot a simple method

* No preflight unless custom headers are added.

Common Server Patterns

1. Wildcard (public API, no auth)

# Nginx
add_header Access-Control-Allow-Origin *;

# Express
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  next();
});

Best for: public APIs, open data, no cookies/auth.

2. Specific origin

# Nginx — single origin
add_header Access-Control-Allow-Origin "https://myapp.com";

# Express — dynamic origin
const ALLOWED = ['https://myapp.com', 'https://admin.myapp.com'];
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (ALLOWED.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  next();
});

Always set Vary: Origin when the value changes based on the Origin header — prevents caching bugs.

3. Credentials (cookies, auth headers)

# Nginx
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials true;
add_header Vary Origin;

# Express + CORS middleware
const cors = require('cors');
app.use(cors({
  origin: (origin, callback) => {
    if (ALLOWED.includes(origin)) callback(null, true);
    else callback(new Error('Not allowed by CORS'));
  },
  credentials: true
}));

# Client-side
fetch('https://api.example.com/me', {
  credentials: 'include'  // sends cookies
})

4. Full preflight handler

# Express — full CORS + preflight
app.options('*', cors());  // enable preflight for all routes

# Or manual:
app.options('/api/*', (req, res) => {
  const origin = req.headers.origin;
  if (ALLOWED.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Request-ID');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Max-Age', '86400');
    res.setHeader('Vary', 'Origin');
    res.sendStatus(204);
  } else {
    res.sendStatus(403);
  }
});

5. Expose custom response headers

res.setHeader('Access-Control-Expose-Headers',
  'X-Total-Count, X-Request-Id, X-RateLimit-Remaining');

// Now JS can read:
// response.headers.get('X-Total-Count')  ✅
// response.headers.get('X-Request-Id')   ✅

Troubleshooting Guide

Common errors

❌ "has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header"

Cause: Server didn't send Access-Control-Allow-Origin at all, or the origin doesn't match.

Fix: Add the header to the response. Check that the value exactly matches the requesting Origin (including trailing slashes).

❌ "The value of the 'Access-Control-Allow-Origin' must not be '*' when credentials mode is 'include'"

Cause: Using wildcard * with credentials: 'include' or withCredentials: true.

Fix: Return the specific origin instead of *.

❌ "header is not allowed by Access-Control-Allow-Headers in preflight response"

Cause: Request uses a header not listed in Access-Control-Allow-Headers.

Fix: Add the header to Access-Control-Allow-Headers. Common missing ones: Authorization, Content-Type (for JSON).

❌ "Method not allowed by Access-Control-Allow-Methods in preflight response"

Cause: Using PUT, PATCH, DELETE etc. and server doesn't list them.

Fix: Add the method to Access-Control-Allow-Methods.

❌ Redirect during preflight

Cause: Server redirects (301/302) the OPTIONS request. Browsers reject preflight redirects.

Fix: Ensure OPTIONS is handled before any redirects. Don't redirect HTTP→HTTPS for the OPTIONS request.

CORS doesn't apply to…

Debug checklist

  1. Check the OPTIONS response — open DevTools → Network → filter OPTIONS — are CORS headers present?
  2. Check the actual response — CORS headers must be on both the preflight and the real response.
  3. Check redirect chains — CORS headers must survive redirects; some servers strip them on 301/302.
  4. Check Vary: Origin — missing Vary can cause wrong cached responses.
  5. Check authentication — is the OPTIONS request being rejected by auth middleware? OPTIONS must pass without auth.
  6. Check nginx/proxy layersadd_header only applies to 2xx by default in nginx. Use always: add_header ... always;

Quick test

Copy and run this in your terminal to test CORS headers on any endpoint:

curl -H "Origin: https://example.com" \
     -H "Access-Control-Request-Method: POST" \
     -H "Access-Control-Request-Headers: Content-Type,Authorization" \
     -X OPTIONS \
     -i \
     https://your-api.com/endpoint

Look for Access-Control-* headers in the response.

Copied!