API Design

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.

⏱ 12 min Difficulty: core Prereq: HTTP basics, browsers

By the end you'll be able to

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 AURL BSame origin?Why
https://app.example.com/pagehttps://app.example.com/api✓ YesScheme, host, port (443) all match
https://app.example.comhttp://app.example.com✗ NoScheme differs (https ≠ http)
https://app.example.comhttps://api.example.com✗ NoSubdomain differs
https://example.com:443https://example.com:8443✗ NoPort 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:

HeaderDirectionMeaning
Access-Control-Allow-OriginResponseWhich origin(s) may read this response. * = any origin (no credentials).
Access-Control-Allow-MethodsResponse (preflight)HTTP methods the server permits: GET, POST, PATCH.
Access-Control-Allow-HeadersResponse (preflight)Request headers the client is allowed to send: Authorization, Content-Type.
Access-Control-Allow-CredentialsResponseIf true, the browser will expose the response when cookies/auth are included.
Access-Control-Max-AgeResponse (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.

Browser frontend.example.com API Server api.example.com ① PREFLIGHT OPTIONS /v1/data Origin: https://frontend.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Authorization ② PREFLIGHT RESPONSE 204 No Content Access-Control-Allow-Origin: https://frontend.example.com Access-Control-Allow-Methods: POST, GET Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 86400 ③ ACTUAL REQUEST POST /v1/data (browser now allows it) 200 OK {…} + Access-Control-Allow-Origin repeated
The browser sends a free OPTIONS preflight before the real request. If the CORS headers match, the real request proceeds. Both the preflight response and the actual response must carry the Allow-Origin header.
# 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.

🎯 Interview angle

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."

⚠️ Common trap

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 this, not that

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:

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 makesPreflight?Why
GET, no custom headersNoSimple — method + headers + content-type all safelisted
POST with Content-Type: text/plainNoSafelisted content-type, no custom headers
POST with Content-Type: application/jsonYesJSON is not a safelisted content-type
Any request with AuthorizationYesAuthorization is not a safelisted header
PUT / PATCH / DELETEYesMethod is not GET/HEAD/POST
⚠️ The nuance that bites everyone: a simple request still reaches your server

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:

$ curl -i -X OPTIONS https://api.example.com/v1/data \ -H "Origin: https://frontend.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: authorization,content-type" 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 # ^ if any of these are MISSING or don't match your Origin/method/header, # that is precisely the line the browser will complain about.

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)CauseFix
"No 'Access-Control-Allow-Origin' header is present"Server sent no ACAO for this route — often because an error/auth path skips the CORS middlewareEnsure 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 oneEcho 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 OPTIONSAnswer OPTIONS with 204 + the Allow-* headers
"Method PATCH is not allowed by Access-Control-Allow-Methods"Method missing from Allow-MethodsAdd it to Access-Control-Allow-Methods
"Request header field authorization is not allowed by Access-Control-Allow-Headers"Header missing from Allow-HeadersAdd authorization to Access-Control-Allow-Headers
"…credentials mode is 'include'… 'Access-Control-Allow-Origin' … must not be the wildcard '*'"* used with credentialsEcho the exact origin + add Access-Control-Allow-Credentials: true

Debug checklist:

  1. 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.
  2. In Network, is there an OPTIONS preflight? Open it and read the response headers.
  3. Compare the request's Origin, method, and headers against the server's Allow-Origin/Allow-Methods/Allow-Headers — find the mismatch.
  4. If credentials are involved, verify Allow-Origin is a specific origin (not *) and Allow-Credentials: true is present.
  5. Add Vary: Origin if you echo the origin, so a CDN doesn't cache one origin's ACAO and 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:

L_preflighted = (RTT + T_options) + (RTT + T_server) = 2·RTT + T_options + T_server

When the preflight result is cached in the browser ($L_{\text{cached}}$):

L_cached = RTT + T_server

The monthly request volume $V_{\text{monthly}}$ without caching is:

V_monthly = 2 * QPS * 86,400 * 30

The monthly request volume with preflight caching ($V_{\text{cached\_monthly}}$) over $U$ unique users refetching every $H$ hours is:

V_cached_monthly = (QPS * 86,400 * 30) + (U * 30 * (24 / H))

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):

🧠 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:

  1. Access-Control-Allow-Origin: * plus Allow-Credentials: true is invalid — browsers reject it. Credentialed responses require an explicit origin, not a wildcard.
  2. Access-Control-Allow-Methods: * and Allow-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

Sources & further reading