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.
| 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 |
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` |
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.
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.
| 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) |
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.
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.
Access-Control-Expose-Headers: X-Secret
Without this line response.headers.get('X-Secret') printed null. With it, the same call returned abc.
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.
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.
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.
| 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 |
GET or POST response needs Access-Control-Allow-Origin too. The browser checks both responses. 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.Origin without an allowlist, together with Allow-Credentials: true, lets every site read logged-in responses. Compare against a fixed list.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.GET /none). CORS does not protect an API from CSRF or unauthenticated callers; it only controls which pages may read responses.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.Access-Control-* lines to the output yourselfconnect-src can block a request before CORS is even checked