CORS: What the Browser Enforces and What It Does Not
CORS is a browser policy, not a server security control. Understanding the Same-Origin Policy, preflight requests, and credential handling prevents both configuration mistakes and false security assumptions.
Why CORS exists
The Same-Origin Policy (SOP) is the browser's default rule: a script on https://app.example.com cannot read the response from a fetch to https://api.other.com. The origin — scheme, hostname, and port — must match, or the browser blocks the response.
SOP exists because without it, any page you visit could make authenticated requests to your bank, email, or internal tools on your behalf — using your session cookies — and read the results.
CORS is the controlled relaxation of SOP. It lets a server tell the browser: "I will accept requests from these other origins." The browser enforces the server's declaration. The server does not enforce it itself.
What CORS actually controls
ConceptBrowser SecurityCORS is enforced by the browser during cross-origin fetch requests. It governs whether the browser will show a script the response it received — not whether the server will accept the request.
Prerequisites
- HTTP headers
- fetch/XHR basics
- session cookies
Key Points
- CORS is a browser mechanism. curl, Postman, and server-to-server calls are never subject to CORS.
- The browser sends the request regardless of CORS policy. If the server's response lacks the correct headers, the browser hides the response from the script — the request still hit the server.
- Simple requests (GET, POST with non-custom content types) do not trigger a preflight.
- Preflight (OPTIONS) fires for requests with custom headers, non-simple methods (PUT, DELETE, PATCH), or non-simple content types.
- Credentials (cookies, Authorization headers) require explicit opt-in on both sides.
Simple requests vs preflighted requests
Simple requests meet all three conditions: method is GET, HEAD, or POST; Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain; no custom headers. The browser sends the request directly and checks the response headers.
Everything else triggers a preflight: the browser first sends an OPTIONS request asking the server which origins, methods, and headers are permitted.
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
The server responds with its permissions:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
Access-Control-Max-Age caches the preflight result so subsequent requests from the same origin skip the round-trip.
If the preflight fails — the origin is not listed, the method is not permitted — the browser blocks the actual request from being sent at all.
The headers and what each one does
| Header | Direction | Purpose |
|---|---|---|
| Access-Control-Allow-Origin | Response | Which origin(s) may read this response. Use a specific origin, not *, if credentials are involved. |
| Access-Control-Allow-Methods | Preflight response | Which HTTP methods are permitted. |
| Access-Control-Allow-Headers | Preflight response | Which request headers are permitted. |
| Access-Control-Allow-Credentials | Response | Whether cookies and Authorization headers are included. Must be true, not *. |
| Access-Control-Max-Age | Preflight response | How long in seconds to cache the preflight result. |
| Access-Control-Expose-Headers | Response | Which response headers scripts may read beyond the default safe set. |
| Origin | Request | The requesting origin, set by the browser automatically. |
Credentials: the part that breaks most CORS configs
When a fetch includes credentials — cookies or an Authorization header — the browser applies stricter rules:
- The request must be sent with
credentials: 'include'. - The server response must include
Access-Control-Allow-Credentials: true. Access-Control-Allow-Originmust be a specific origin, not*.
// Client
fetch('https://api.example.com/user', {
credentials: 'include', // sends cookies
});
// Required server response headers
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is invalid. Browsers reject this combination. This is the most common CORS misconfiguration: developers set * to "fix" CORS errors and then discover credential requests still fail.
⚠The localhost misconfiguration
Allowing http://localhost:3000 in a production CORS policy means any developer on any machine running a frontend on that port can make credentialed cross-origin requests to your API — as long as they have a valid session cookie. Since your production API is publicly accessible, they do.
In production, restrict Access-Control-Allow-Origin to your deployed frontend domains. Keep localhost only in development environment config. Never merge it into production.
CORS does not protect your API
This is the part most teams get wrong.
CORS is enforced by the browser. curl, Postman, Python's requests, or any server-side code is not a browser — none of them check CORS headers. An attacker who wants to call your API directly can do so without restriction. CORS does not stop that.
What CORS prevents: a malicious website running in someone's browser from making credentialed requests to your API on behalf of that user, and reading the responses.
# This works regardless of your CORS policy
curl -X DELETE https://api.example.com/users/42 \
-H "Authorization: Bearer <stolen_token>"
Your actual API protection is authentication and authorization — verifying the token, checking the caller has permission. CORS is a second layer that prevents cross-origin browser scripts from silently acting as an authenticated user.
CORS vs CSRF: different threat models
Both involve cross-origin requests, but they protect against different attacks.
- A malicious page reading responses from cross-origin requests made by browser scripts
- Leaking sensitive API data to an unauthorized origin
- Cross-origin JavaScript access to authenticated API responses
- A malicious page triggering state-changing requests using the victim's cookies
- Forged form submissions and mutations executed with the victim's identity
- Attacks that don't need to read the response — only trigger the action
CORS and CSRF address different attack vectors. A strict CORS policy does not prevent CSRF — an attacker can still trigger mutations via a simple form POST (which does not trigger preflight). Proper CSRF protection (SameSite cookies, CSRF tokens) is separate.
Your API returns Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. A browser makes a credentialed cross-origin fetch. What happens?
mediumThe API intends to allow all origins and also allow cookies to be sent.
AThe request succeeds — wildcard allows everything including credentials
Incorrect.Browsers explicitly reject the combination of wildcard origin and credentials. This is defined in the CORS specification.BThe browser blocks the request and logs a CORS error
Correct!When credentials are involved, Access-Control-Allow-Origin must be a specific origin, never *. The wildcard + credentials combination is invalid and the browser refuses to expose the response to the script.CThe server rejects the request because the headers are invalid
Incorrect.The server sends these headers — it does not validate them itself. The browser enforces CORS on the client side.DThe request works but cookies are stripped
Incorrect.The browser does not partially comply with CORS. It either exposes the response to the script or it does not.
Hint:CORS is enforced by the browser. Think about what the browser specification says about wildcard origins and credentials.