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

⚠️ 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 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:

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.

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

  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