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 the Same-Origin Policy is designed to limit
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/accounts/me", { credentials: "include" }). Your browser automatically attaches your bank session cookie — you are authenticated. Without any protection, the bank response (balances, account numbers) would be readable by evil.com's script.
The Same-Origin Policy (SOP) is the browser's primary defence against cross-origin response reading: by default, a script on one origin may not read the response of a request to a different origin — even when cookies were sent. Closely related but distinct: Cross-Site Request Forgery (CSRF) is about forging a state-changing request (transfer money, change email) that the browser still sends with cookies. SOP/CORS do not fully stop CSRF for simple requests; you need SameSite cookies, CSRF tokens, or non-cookie auth for that.
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 controls whether cross-origin JavaScript may read a response. For simple requests the call can still hit your server; the browser only blocks JS from seeing the body. CORS is not a CSRF defence and 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 combine Access-Control-Allow-Origin: * with credentialed cookie mode (Allow-Credentials: true / fetch credentials: "include") — the browser refuses that pair. Note: a bare Authorization: Bearer header does not require credentials mode and can legally pair with *; still prefer an explicit allow-list for first-party browser apps.
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.
By the numbers
Let's calculate the latency penalty, request amplification, and API Gateway billing costs generated by CORS preflight OPTIONS requests. Consider a client on a mobile network with 120 ms RTT (Round Trip Time) calling a JSON API. The API processes requests in 40 ms. Our website receives 5,000 cross-origin requests/sec (QPS) from a population of 1,000,000 active unique clients. We compare the metrics with and without preflight caching.
Governing Formulas
The total latency for a preflighted request $L_{\text{preflighted}}$ (when the cache is empty or missing) is:
When the preflight result is cached in the browser ($L_{\text{cached}}$):
The monthly request volume $V_{\text{monthly}}$ without caching is:
The monthly request volume with preflight caching ($V_{\text{cached\_monthly}}$) over $U$ unique users refetching every $H$ hours is:
Step-by-Step Scenario Analysis
Let's trace the network exchanges and latencies for a client making two sequential API calls under different configurations:
| Call Sequence | Uncached CORS (Max-Age = 0) | Cached CORS (Max-Age = 86400) | Latency Saved |
|---|---|---|---|
| Call 1: OPTIONS (Preflight) | 120 ms network + 5 ms server = 125 ms | 120 ms network + 5 ms server = 125 ms | 0 ms (Initial cache miss) |
| Call 1: POST (Real payload) | 120 ms network + 40 ms server = 160 ms | 120 ms network + 40 ms server = 160 ms | 0 ms |
| Call 2: OPTIONS (Preflight) | 120 ms network + 5 ms server = 125 ms | 0 ms (Served from browser cache) | 125 ms |
| Call 2: POST (Real payload) | 120 ms network + 40 ms server = 160 ms | 120 ms network + 40 ms server = 160 ms | 0 ms |
| Total Latency (2 Calls) | $125 + 160 + 125 + 160 = \mathbf{570\text{ ms}}$ | $125 + 160 + 0 + 160 = \mathbf{445\text{ ms}}$ | 125 ms (22% faster) |
Decision Math: Sizing the Financial Savings
Let's calculate the API Gateway billing overhead at a peak load of 5,000 QPS over a 30-day month (assuming AWS API Gateway HTTP API pricing of $1.00 per million requests, and REST API pricing of $3.50 per million):
- Requests without cache: $5,000 \text{ QPS} \times 86,400 \text{ s/day} \times 30 \text{ days} \times 2 = \mathbf{25.92\text{ billion requests}}$.
- Requests with cache (1M clients, cached 24h): $12.96\text{B (POSTs)} + (1\text{M} \times 30\text{ days} \times 1\text{ OPTIONS}) = \mathbf{12.99\text{ billion requests}}$.
- Monthly request volume saved: $25.92\text{B} - 12.99\text{B} = \mathbf{12.93\text{ billion requests}}$ (a 50% reduction).
- Monthly Billing Savings:
- HTTP API: $12,930\text{ million} \times \$1.00 = \mathbf{\$12,930\text{ per month}}$.
- REST API: $12,930\text{ million} \times \$3.50 = \mathbf{\$45,255\text{ per month}}$ saved in raw network bills by setting one single header:
Access-Control-Max-Age: 86400.
🧠 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 a Content-Type: application/json header. What is this OPTIONS request called, and why is it sent?
A preflight OPTIONS is sent for any request that is not "simple": this includes custom headers like Authorization or X-Request-ID, Content-Type: application/json (only application/x-www-form-urlencoded, multipart/form-data, and text/plain are simple), and methods other than GET, HEAD, or plain POST. The browser sends OPTIONS first to get permission from the server before the real request.
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. Remember to also set Vary: Origin so caches don't serve one origin's CORS response to a different origin.
✍️ 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 not a complete CSRF defence (simple state-changing requests can still fire with cookies).
- 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.