Cheatsheet

CORS Cheatsheet

This cheatsheet is a lookup for Cross-Origin Resource Sharing (CORS): which headers the browser sends, which the server must answer with, when a preflight happens, and what each console error means. It is for developers whose fetch() call works in curl and fails in the browser. The one confusion it clears up is that CORS is enforced by the browser on reading the response; the server still receives and processes the request. The concept is covered in the CORS glossary entry.

Quick reference

Response headers the server sets

Header Value Purpose
`Access-Control-Allow-Origin` `*`, or one exact origin, or `null` Which origin may read the response
`Access-Control-Allow-Methods` `GET, POST, PUT, DELETE` Methods allowed (preflight response)
`Access-Control-Allow-Headers` `Content-Type, X-Token` Request headers allowed (preflight response)
`Access-Control-Allow-Credentials` `true` Allow cookies and HTTP auth; the only valid value is `true`
`Access-Control-Expose-Headers` `X-Secret, ETag` Extra response headers JavaScript may read
`Access-Control-Max-Age` seconds, such as `600` How long the browser caches the preflight result
`Vary` `Origin` Tells caches the response differs per origin

Request headers the browser sets

You cannot set these from JavaScript; the browser adds them.

Header Sent on Example
`Origin` Cross-origin requests `Origin: http://localhost:8080`
`Access-Control-Request-Method` Preflight `PUT`
`Access-Control-Request-Headers` Preflight `content-type,x-token`

What triggers a preflight

A preflight is an OPTIONS request sent before the real one. It happens unless the request is "simple":

Part No preflight if
Method `GET`, `HEAD` or `POST`
Headers Only CORS-safelisted ones (below)
`Content-Type` `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`

CORS-safelisted request headers are Accept, Accept-Language, Content-Language, Content-Type (with the three types above) and Range (a simple byte range only). Each safelisted value must be 128 bytes or shorter, or the header counts as unsafe.

Response headers readable without Expose-Headers

Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Everything else returns null from response.headers.get() until you list it in Access-Control-Expose-Headers.

Preflight cache limits

Setting Value
Default when `Access-Control-Max-Age` is absent 5 seconds
Chromium cap (since v76) 7200 seconds (2 hours)
Firefox cap 86400 seconds (24 hours)

Common patterns

Allow one origin with cookies

Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Credentials: true
Vary: Origin

The browser sent sid=42 and the page read cookie=sid=42 with this response and credentials: 'include' on the call. Echo the request's Origin only after checking it against an allowlist. Always add Vary: Origin.

Answer a preflight for a JSON POST

OPTIONS /pre   (browser asks)
  Access-Control-Request-Method: POST
  Access-Control-Request-Headers: content-type,x-token

204 No Content (server answers)
  Access-Control-Allow-Origin: http://localhost:8080
  Access-Control-Allow-Methods: GET, POST, PUT, DELETE
  Access-Control-Allow-Headers: Content-Type, X-Token
  Access-Control-Max-Age: 600

The server log showed OPTIONS /pre followed by POST /pre. The preflight needs only a 2xx status and the headers; no body.

Read a custom response header

Access-Control-Expose-Headers: X-Secret

Without this line response.headers.get('X-Secret') printed null. With it, the same call returned abc.

Send JSON without a preflight

fetch('http://localhost:8081/pre', {
  method: 'POST',
  headers: { 'Content-Type': 'text/plain' },
  body: JSON.stringify({ a: 1 })
});

The server saw only POST /pre, with no OPTIONS. This skips the preflight but the server must then parse the body despite the text/plain type. A JSON POST with application/json always preflights.

Prove the preflight cache works

no Max-Age:  PUT, wait 6.5 s, PUT  ->  OPTIONS, PUT, OPTIONS, PUT
Max-Age 600: PUT, wait 6.5 s, PUT  ->  OPTIONS, PUT, PUT

Both lines are server logs from Chromium 153. Without Access-Control-Max-Age the 5-second default expired between calls, so the second PUT preflighted again.

A minimal Node allowlist handler

const http = require("http");
const allowed = new Set(["http://localhost:8080"]);
http.createServer((req, res) => {
  const origin = req.headers.origin;
  if (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, X-Token");
    res.setHeader("Access-Control-Max-Age", "600");
    res.statusCode = 204;
    return res.end();
  }
  res.end("ok");
}).listen(8092);

Requesting OPTIONS with two different Origin headers printed one line each, in the form origin, status, allow-origin header, vary header:

http://localhost:8080 204 http://localhost:8080 Origin
http://evil.test 204 null null

The unknown origin gets a normal response with no CORS headers, so the browser refuses to expose it to that page. Do not reply with an error status for unknown origins; the missing header is the signal the browser reads. This pattern works behind any framework, because it only touches headers and the status code.

Error messages and fixes

Console message (Chromium) Fix
No 'Access-Control-Allow-Origin' header is present on the requested resource Add `Access-Control-Allow-Origin` to the actual response, including error responses
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include' Return the exact origin instead of `*`
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response Add that header name to `Access-Control-Allow-Headers` on the `OPTIONS` response
Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response Same fix; the preflight response lacked `Content-Type` in the allow list
Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request Serve `OPTIONS` at the final URL; a 307 on the preflight fails the request

Pitfalls

  • Sending the CORS headers only on the preflight: the actual GET or POST response needs Access-Control-Allow-Origin too. The browser checks both responses.
  • *Using with credentials:* Access-Control-Allow-Origin: with credentials: 'include' is blocked. The same ban applies to * in Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers on credentialed requests.
  • Reflecting any Origin back: echoing the request's Origin without an allowlist, together with Allow-Credentials: true, lets every site read logged-in responses. Compare against a fixed list.
  • Forgetting Vary: Origin: when the allowed origin changes per request, a shared cache can serve a response bound to one origin to another and break the second site.
  • Thinking mode: 'no-cors' fixes it: the request is sent, but the response is opaque. The test returned type=opaque status=0, with no readable body or headers.
  • Blaming the browser for a server-side effect: a simple cross-origin request blocked by CORS still reached the server (the log showed GET /none). CORS does not protect an API from CSRF or unauthenticated callers; it only controls which pages may read responses.
  • Redirecting the preflight: a 307 on the OPTIONS request failed with the redirect error above, so a trailing-slash redirect on an API route can break CORS only for non-simple requests. Answer OPTIONS at the final URL, and call the canonical URL from the client.
  • Setting a small Max-Age and wondering about extra OPTIONS calls: with no value you get 5 seconds, so a chatty client preflights constantly.

Related ZipKit tools

  • Nginx Reverse Proxy Generator — scaffolds Nginx configs; it does not generate CORS headers, so add the Access-Control-* lines to the output yourself

Related cheatsheets