Security · Lesson 04
CORS, explained
Every frontend developer has hit a CORS error. Most fix it by cargo-culting headers until it stops. This lesson explains what is actually happening — why the browser enforces it, what CORS headers mean, and the one mistake that secretly undoes the protection.
By the end you'll be able to
- Explain the Same-Origin Policy and why browsers enforce it by default.
- Read the five main CORS response headers and state what each one permits.
- Describe when a preflight OPTIONS request is triggered and what it checks.
The threat CORS is designed to prevent
Imagine you are logged in to your bank at bank.example.com. You open a second tab and visit evil.example.com. That page's JavaScript runs fetch("https://bank.example.com/v1/transfer", { method: "POST", body: "...", credentials: "include" }). Your browser automatically attaches your bank session cookie to that request — you are authenticated. Without any protection, the transfer goes through and evil.com never even needed your password.
This attack class is called Cross-Site Request Forgery (CSRF). The Same-Origin Policy (SOP) is the browser's primary defence against it: by default, a script running on one origin may not read the response of a request to a different origin.
What is an "origin"?
An origin is the exact combination of scheme + hostname + port. All three must match for two URLs to share an origin:
| URL A | URL B | Same origin? | Why |
|---|---|---|---|
https://app.example.com/page | https://app.example.com/api | ✓ Yes | Scheme, host, port (443) all match |
https://app.example.com | http://app.example.com | ✗ No | Scheme differs (https ≠ http) |
https://app.example.com | https://api.example.com | ✗ No | Subdomain differs |
https://example.com:443 | https://example.com:8443 | ✗ No | Port differs |
The SOP means a page at https://frontend.example.com and an API at https://api.example.com are cross-origin — even though they share the same second-level domain. Without CORS, the browser will block the frontend from reading the API's response.
CORS — controlled relaxation of the SOP
CORS (Cross-Origin Resource Sharing) is an HTTP header protocol that lets the API server tell the browser: "I permit requests from this origin." It is not a way to bypass security — it is a precise, opt-in mechanism to declare which cross-origin callers are trusted.
The five headers you need to know:
| Header | Direction | Meaning |
|---|---|---|
Access-Control-Allow-Origin | Response | Which origin(s) may read this response. * = any origin (no credentials). |
Access-Control-Allow-Methods | Response (preflight) | HTTP methods the server permits: GET, POST, PATCH. |
Access-Control-Allow-Headers | Response (preflight) | Request headers the client is allowed to send: Authorization, Content-Type. |
Access-Control-Allow-Credentials | Response | If true, the browser will expose the response when cookies/auth are included. |
Access-Control-Max-Age | Response (preflight) | How many seconds the browser may cache this preflight result. |
The preflight request
For "non-simple" requests (anything other than a GET/HEAD/POST with plain form content-type, or any request with custom headers like Authorization), the browser sends an automatic OPTIONS request first — the preflight. This asks the server: "Would you accept the real request I am about to send?" Only if the server's CORS headers say yes does the browser proceed with the actual request.
# A correctly configured CORS preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
# The actual POST response must also repeat the Allow-Origin header:
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://frontend.example.com
CORS is enforced by the browser, not the server
This is the single most-misunderstood fact about CORS. The server sends headers expressing its policy. The browser reads those headers and decides whether to expose the response to the JavaScript. A server with no CORS headers will still receive and process every request — it just won't tell the browser the response is safe to share with cross-origin scripts.
What this means in practice: CORS does nothing to protect your API from curl, Postman, a mobile app, a server-side script, or any non-browser client. Those tools ignore CORS headers entirely. CORS protects browser users from being used as unwitting request-makers on a malicious page — it does not protect the API itself from direct calls.
A very common question: "Does CORS secure your API?" The right answer is: "No — CORS is a browser-side mechanism that prevents cross-origin JavaScript from reading responses. It stops a malicious web page from using a victim's browser to call your API silently. It offers no protection against curl, scripts, or any non-browser tool." Then add: "For that you need authentication on every request, plus CSRF tokens or SameSite cookies for session-authenticated endpoints."
Two CORS misconfigurations that undo the protection entirely:
1. Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true. Browsers block this combination precisely because it would let any origin read credentialed responses. If you need credentials, you must echo the specific allowed origin, not *.
2. Reflecting the Origin header blindly. An API that responds with Access-Control-Allow-Origin: <whatever Origin header was sent> without checking it against an allow-list is equivalent to * — any origin is trusted. Always maintain an explicit list of permitted origins and return the Allow-Origin header only for those.
Do maintain an explicit allow-list of origins in your gateway or middleware. Do use Access-Control-Max-Age (e.g. 86400 seconds) to cache preflight results and avoid a round-trip on every request. Don't set Access-Control-Allow-Origin: * on endpoints that accept cookies or Authorization headers — the browser will refuse anyway, and you've signalled you don't understand the model.
Under the hood: simple vs preflighted requests
The browser splits cross-origin requests into two categories, and the rules for which is which are exact — not vibes. A request is "simple" (no preflight) only if it meets all of these conditions:
- Method is
GET,HEAD, orPOST. - The only headers your code set are on the CORS "safelist" (
Accept,Accept-Language,Content-Language,Content-Type, plus a couple more). Content-Type, if present, is one ofapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.
Break any of those and the request becomes "preflighted". In practice almost every real API call is preflighted, because two extremely common things both trip it: sending Authorization: Bearer … (a non-safelisted header) and sending Content-Type: application/json (not on the safelisted content-type list). That single JSON header is why your innocent-looking fetch suddenly fires an OPTIONS first.
| The request your JS makes | Preflight? | Why |
|---|---|---|
GET, no custom headers | No | Simple — method + headers + content-type all safelisted |
POST with Content-Type: text/plain | No | Safelisted content-type, no custom headers |
POST with Content-Type: application/json | Yes | JSON is not a safelisted content-type |
Any request with Authorization | Yes | Authorization is not a safelisted header |
PUT / PATCH / DELETE | Yes | Method is not GET/HEAD/POST |
CORS does not stop the request from executing. For a simple cross-origin request, the browser sends it, your server runs it, and only then does the browser withhold the response from the page's JavaScript if the CORS headers don't match. So a cross-origin POST can still create a row in your database — the attacker's script just can't read what came back. That is exactly why CORS is not a CSRF defence and why state-changing endpoints need their own protection (SameSite cookies, CSRF tokens, auth). A preflighted request is different: if the OPTIONS is rejected, the browser never sends the real request at all — so the preflight is a genuine gate, but only for non-simple requests.
How to debug & inspect it
Three facts make CORS debugging fast once you internalise them: (1) the error is always in the browser console, never your server logs; (2) the fix is always a response header from your server; (3) curl and Postman will happily succeed on the same URL because they don't enforce CORS — so "works in curl, fails in browser" is the classic signature, not a contradiction.
Reproduce the preflight yourself with curl by sending the OPTIONS the browser would send, and read what comes back:
In DevTools: open Network, find the OPTIONS row (the preflight) sitting just before your real call; inspect its response headers. The failed real request shows status (blocked:cors). Map the red console message to its cause:
| Console message (abridged) | Cause | Fix |
|---|---|---|
| "No 'Access-Control-Allow-Origin' header is present" | Server sent no ACAO for this route — often because an error/auth path skips the CORS middleware | Ensure CORS headers are set on every response, including 4xx/5xx and OPTIONS |
| "…'Access-Control-Allow-Origin' header has a value … that is not equal to the supplied origin" | ACAO is hard-coded to one origin, or echoes the wrong one | Echo the request's Origin after checking it against your allow-list |
| "Response to preflight request doesn't pass … (status 404/405)" | Your router doesn't handle OPTIONS | Answer OPTIONS with 204 + the Allow-* headers |
| "Method PATCH is not allowed by Access-Control-Allow-Methods" | Method missing from Allow-Methods | Add it to Access-Control-Allow-Methods |
| "Request header field authorization is not allowed by Access-Control-Allow-Headers" | Header missing from Allow-Headers | Add authorization to Access-Control-Allow-Headers |
| "…credentials mode is 'include'… 'Access-Control-Allow-Origin' … must not be the wildcard '*'" | * used with credentials | Echo the exact origin + add Access-Control-Allow-Credentials: true |
Debug checklist:
- Confirm it's actually CORS: is the error in the console and does the same URL succeed in
curl? If yes → CORS, not your server logic. - In Network, is there an
OPTIONSpreflight? Open it and read the response headers. - Compare the request's
Origin, method, and headers against the server'sAllow-Origin/Allow-Methods/Allow-Headers— find the mismatch. - If credentials are involved, verify
Allow-Originis a specific origin (not*) andAllow-Credentials: trueis present. - Add
Vary: Originif you echo the origin, so a CDN doesn't cache one origin'sACAOand serve it to another.
🧠 Quick check
1. A developer sets Access-Control-Allow-Origin: * on an API endpoint that also returns Access-Control-Allow-Credentials: true. What happens?
The CORS spec explicitly forbids combining * with credentials. If both headers are present, the browser will reject the response with a CORS error regardless of the request origin.
2. Which of these clients is NOT affected by CORS headers?
CORS is enforced by the browser's JavaScript engine. Non-browser HTTP clients (curl, Python requests, Postman) do not enforce CORS — they send and receive without any cross-origin check.
3. A browser sends an OPTIONS request before a POST with an Authorization header. What is this OPTIONS request called, and why is it sent?
Non-simple requests (those with custom headers like Authorization, or methods other than GET/HEAD/plain POST) trigger an automatic preflight OPTIONS to get permission before the real request is sent.
4. An API sets Access-Control-Allow-Origin to whatever value the incoming Origin header contains. What is the security implication?
Blindly reflecting the Origin header means any origin gets back an Allow-Origin that matches itself. This defeats the allow-list entirely. Always validate the incoming Origin against a known-good list before echoing it.
✍️ Exercise: fix a broken CORS configuration (try before opening)
Your API gateway is configured with these CORS headers for all responses:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *
Access-Control-Allow-Credentials: true
List two specific problems with this configuration and write a corrected version for a single-page app hosted at https://dashboard.company.com that needs to call the API with session cookies.
Model answer:
Problems:
Access-Control-Allow-Origin: *plusAllow-Credentials: trueis invalid — browsers reject it. Credentialed responses require an explicit origin, not a wildcard.Access-Control-Allow-Methods: *andAllow-Headers: *are overly broad and in some browser versions are not honoured for credentialed requests. Enumerate only what the API actually uses.
# Corrected configuration
Access-Control-Allow-Origin: https://dashboard.company.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Rubric: ✓ identified wildcard + credentials conflict ✓ replaced * with an explicit origin ✓ enumerated specific methods and headers ✓ added Max-Age to reduce preflight frequency.
Key takeaways
- The Same-Origin Policy blocks cross-origin scripts from reading responses by default — it is the browser's guard against CSRF-style abuse.
- An origin is scheme + hostname + port; all three must match.
- CORS headers let the server opt specific origins in, method by method, header by header.
- Non-simple requests trigger an OPTIONS preflight; the browser only sends the real request if the preflight is approved.
- CORS is browser-enforced — it does not protect the API from non-browser callers; you still need auth.
- Never combine
Access-Control-Allow-Origin: *withAllow-Credentials: true— the browser blocks it, and it would be insecure if it didn't.