CORS is a browser feature!
CORS is one of those topics where the name sounds like server-side security, the error shows up in frontend console, and the fix usually lands in backend config. That combination creates a lot of confusion.
The clean mental model:
CORS is a browser-enforced read permission system for cross-origin responses.
It is not an authentication system. It is not a network firewall. It does not stop servers, curl, Postman, mobile apps, backend jobs, or attackers from sending requests to your API. It only tells a conforming browser whether JavaScript running on one origin is allowed to read a response from another origin.
That distinction matters in architecture reviews. If someone says “we are protected because CORS only allows our frontend domain,” the staff-level response is: protected from which actor?
Same-origin policy first
Browsers start from the same-origin policy.
An origin is:
scheme + host + portThese are different origins:
https://app.example.com
https://api.example.com
https://app.example.com:8443
http://app.example.comThe browser lets a page freely read resources from its own origin. Reading from another origin is restricted unless the other origin explicitly opts in with CORS headers.
This is why CORS exists: modern applications need APIs, fonts, images, auth services, payment widgets, analytics, and separate frontend/backend deployments. CORS is the opt-in mechanism that lets those cross-origin reads work without turning the browser into an ambient data exfiltration machine.
What CORS actually protects
Assume a user is logged into bank.example.com. They visit evil.example.
The malicious page can try:
fetch("https://bank.example.com/api/me", {
credentials: "include",
})The browser may send the request, including cookies depending on cookie settings. CORS does not necessarily stop the request from reaching the bank.
What CORS stops is this:
const data = await response.json()If bank.example.com does not allow evil.example through CORS, the browser blocks JavaScript from reading the response.
So CORS is mainly about confidentiality of browser-readable responses. It prevents arbitrary websites from using the victim’s browser as an authenticated read proxy.
It is not a complete CSRF defense. CSRF is about causing state-changing actions. CORS is about reading responses. Related, but not the same. See SameSite Cookie and CSRF.
Simple requests and preflight requests
Not every cross-origin request gets a preflight.
A “simple” request can be sent directly if it uses simple methods and simple headers. Roughly:
- methods like
GET,HEAD,POST - limited content types like
application/x-www-form-urlencoded,multipart/form-data, ortext/plain - no custom headers like
AuthorizationorX-Request-Id
Anything outside that shape usually causes the browser to send an OPTIONS request first. That is the preflight.
Example:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-typeThe server responds:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization,content-type
Access-Control-Max-Age: 600If the preflight succeeds, the browser sends the real request.
This is why “the request never reaches my controller” is common. The request that failed may have been the OPTIONS preflight, rejected by the gateway, CDN, auth middleware, load balancer, or framework before application code ran.
The core headers
Access-Control-Allow-Origin
This is the central allow list.
Access-Control-Allow-Origin: https://app.example.comIt can also be:
Access-Control-Allow-Origin: *Wildcard is only safe for truly public, non-credentialed resources. It cannot be used with credentialed browser requests.
Access-Control-Allow-Credentials
This allows the browser to expose credentialed responses to JavaScript.
Access-Control-Allow-Credentials: trueCredentials include cookies, TLS client certificates, and HTTP auth. In fetch, the frontend must also opt in:
fetch("https://api.example.com/me", {
credentials: "include",
})For credentialed CORS, the server must return a specific origin, not *.
Access-Control-Allow-Methods
Controls which methods are allowed after preflight.
Access-Control-Allow-Methods: GET,POST,PUT,DELETEKeep this tight. If your browser client only needs GET and POST, do not publish every method.
Access-Control-Allow-Headers
Controls which request headers the browser may send.
Access-Control-Allow-Headers: authorization,content-type,x-request-idIf your frontend sends an Authorization header and the server forgets to allow it, the preflight fails before your endpoint logic runs.
Access-Control-Expose-Headers
Browsers do not expose every response header to JavaScript by default.
If the client needs to read custom response headers:
Access-Control-Expose-Headers: x-request-id,rate-limit-remainingWithout this, the API may return the header correctly and the frontend still cannot see it.
Vary: Origin
If your server reflects allowed origins dynamically, add:
Vary: OriginThis tells caches that responses vary by the Origin request header.
Without it, a CDN or shared cache can store a response generated for one origin and serve it to another origin with the wrong CORS headers. This is one of the easiest production-grade CORS bugs to miss because local testing rarely has the same caching path.
Staff-level rule: CORS is not an API authorization boundary
CORS should never be the only reason an API endpoint is considered safe.
Bad reasoning:
Only
https://app.example.comis in CORS, so nobody else can call our API.
Better reasoning:
CORS limits which browser origins can read responses. The API still authenticates and authorizes every request. Non-browser clients are outside the CORS trust model.
Attackers do not need your browser JavaScript. They can call your API from their own infrastructure. CORS will not run there.
This means every sensitive endpoint still needs:
- authentication
- authorization
- input validation
- rate limiting where appropriate
- audit logging for sensitive actions
- CSRF protection if cookie-authenticated browser flows can mutate state
CORS and CSRF are different failure modes
CORS answers:
Can JavaScript from origin A read a response from origin B?
CSRF answers:
Can a malicious site cause the victim’s browser to perform an authenticated action?
A cross-site form POST can trigger a state-changing request without reading the response. That can be CSRF even when CORS blocks JavaScript from reading anything.
This is why cookie-authenticated apps usually need some combination of:
SameSite=LaxorSameSite=Strict- CSRF tokens for risky state-changing operations
- idempotent GET endpoints
- re-authentication or step-up checks for dangerous actions
See Web Security and SameSite Cookie and CSRF.
CORS and bearer tokens
Token-based SPAs often use:
Authorization: Bearer <token>This changes the shape of the problem.
The Authorization header is not a simple request header, so the browser will preflight. That is normal.
CORS still does not prove the request is trustworthy. It only proves the browser is willing to expose the response to that origin. The token must still be validated by the API. The API must still check scopes, tenant boundaries, object ownership, and user permissions.
A useful staff-level question:
If I remove the browser from the picture and call this endpoint with curl, what prevents abuse?
The answer should be authz, not CORS.
Good CORS policy design
For a production API used by a known frontend:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: authorization,content-type,x-request-id
Access-Control-Expose-Headers: x-request-id
Vary: OriginBut the exact policy should follow the product architecture.
Public API with no credentials
If the resource is public and has no user-specific data:
Access-Control-Allow-Origin: *This is often fine for public docs, open metadata, static assets, or public read-only endpoints.
Do not combine this with credentials.
Internal app API with cookies
Use an explicit origin allow list:
https://app.example.com
https://admin.example.comReturn Access-Control-Allow-Credentials: true only when required.
Also use cookie protections:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=LaxMulti-tenant custom domains
This gets tricky.
If customers can host frontends on custom domains like:
https://customer-a.com
https://customer-b.comyou may need dynamic origin validation. Do not just reflect any Origin.
Bad:
Origin: https://evil.example
Access-Control-Allow-Origin: https://evil.exampleBetter:
- parse the
Origin - normalize it
- compare against a tenant-owned verified domain
- return the exact origin only after validation
- include
Vary: Origin - log rejected origins for investigation
Dynamic CORS is a policy engine, not a string echo.
Common production mistakes
Reflecting every origin
This is the classic footgun:
res.setHeader("Access-Control-Allow-Origin", req.headers.origin)
res.setHeader("Access-Control-Allow-Credentials", "true")That effectively says any website can read credentialed responses from your API in a victim’s browser. If the API uses cookies, this can be severe.
Assuming wildcard means “secure”
Access-Control-Allow-Origin: * means public-to-read from browser JavaScript. It is not a lock. It is a wide-open read permission for non-credentialed browser requests.
Forgetting preflight at the edge
Many CORS bugs live in infrastructure:
- CDN blocks
OPTIONS - API gateway requires auth for
OPTIONS - backend has CORS config but load balancer handles preflight
- WAF strips
Access-Control-*headers - framework CORS middleware runs after auth middleware
Preflight should usually be answered before auth for the actual endpoint, because preflight is a browser permission check, not the business request.
Missing Vary: Origin
If you dynamically allow origins and have any shared cache, missing Vary: Origin can cause cross-origin cache poisoning or confusing intermittent CORS failures.
Allowing too many headers and methods
Broad CORS policies are often copied from StackOverflow:
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *That may be acceptable during local development. In production, make the policy describe the actual browser contract.
Treating localhost casually forever
Development origins are useful:
http://localhost:3000
http://localhost:5173But they should not accidentally ship into production policies unless there is a deliberate reason. Environment-specific CORS config is boring and necessary.
How to debug CORS without guessing
Start with the browser console, but do not stop there.
Check the request in DevTools Network:
- Is there an
Originheader? - Did the browser send a preflight
OPTIONSrequest? - What status did the preflight return?
- Are the
Access-Control-Allow-*headers on the preflight response? - Are they also on the actual response?
- If credentials are used, is
Access-Control-Allow-Credentials: truepresent? - If credentials are used, is
Access-Control-Allow-Origina specific origin rather than*? - Is the requested custom header listed in
Access-Control-Allow-Headers? - Is the frontend using
credentials: "include"when cookies are needed? - Is a CDN caching the wrong CORS response?
You can simulate preflight with curl:
curl -i -X OPTIONS "https://api.example.com/orders" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"And the actual request:
curl -i "https://api.example.com/orders" \
-H "Origin: https://app.example.com" \
-H "Authorization: Bearer example"Remember: curl will show you headers, but curl is not enforcing CORS. The browser is.
Design review checklist
When reviewing CORS for a serious system, ask:
- Which browser origins are allowed in each environment?
- Are credentials required, or can this be token-only / public / same-origin?
- Are we reflecting origins, or validating against an allow list?
- If dynamic, do we send
Vary: Origin? - Are
OPTIONSrequests handled before auth middleware blocks them? - Are methods and headers minimal?
- Are custom response headers exposed only when needed?
- Do state-changing cookie-authenticated endpoints have CSRF protection?
- Does the API still enforce authn/authz when called outside a browser?
- Are production and local-development CORS policies separate?
- Are rejected origins logged without leaking secrets?
Interview-level summary
CORS is a browser feature that relaxes the same-origin policy. Servers do not use CORS to decide whether a request can reach them. Servers use CORS headers to tell browsers which origins are allowed to read responses.
That gives us the most important sentence:
CORS protects browser users from cross-origin response reads; it does not protect APIs from being called.
Use CORS as a browser contract. Use authentication, authorization, CSRF defenses, and rate limiting as security controls.