What is CORS and how does it work in the browser?
CORS (Cross-Origin Resource Sharing) is the browser mechanism that decides whether a page on origin A is allowed to read responses from origin B. The browser sends the request, but won't expose the response to JS unless the server returns the right Access-Control-Allow-* headers. For non-simple requests (custom headers, methods like PUT/DELETE) the browser first sends an OPTIONS 'preflight' to ask permission. CORS is a browser-only enforcement — server-to-server calls bypass it entirely.
CORS is a browser security feature that controls when JavaScript on one origin can read a response from another origin. It's not a server-side firewall — the request still hits your server, but the browser blocks the response from being seen by JS unless the server opts in via headers.
Why it exists
Without CORS, a malicious page evil.com could fetch('https://bank.com/balance') using the user's cookies and read the response. The Same-Origin Policy (SOP) blocks that by default. CORS is the controlled way to opt in to cross-origin reads.
Origins
An origin = scheme + host + port. https://a.com and https://a.com:8080 are different origins. https://a.com and http://a.com are different origins.
Simple vs preflighted requests
Simple requests (no preflight) require:
- Method:
GET,HEAD, orPOST. - Only CORS-safelisted headers (
Accept,Content-Language,Content-Typewith one ofapplication/x-www-form-urlencoded,multipart/form-data,text/plain). - No
ReadableStreambody.
Anything else triggers a preflight OPTIONS request first.
Simple flow
1. Browser → Server:
GET /data HTTP/1.1
Origin: https://app.com
2. Server → Browser:
200 OK
Access-Control-Allow-Origin: https://app.com
3. Browser exposes body to JS.If Access-Control-Allow-Origin is missing or doesn't match → browser blocks JS access (but the request was made — important for non-idempotent endpoints).
Preflight flow
1. Browser → Server:
OPTIONS /data
Origin: https://app.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
2. Server → Browser:
200 OK
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
3. Browser sends the real PUT.Access-Control-Max-Age caches the preflight result (e.g., 24h), avoiding the OPTIONS roundtrip on every subsequent request.
Credentials (cookies, Authorization)
By default, cross-origin fetch does not send cookies. To include them:
fetch(url, { credentials: 'include' });The server must then respond with:
Access-Control-Allow-Origin: https://app.com (NOT *)
Access-Control-Allow-Credentials: true* is rejected with credentials — you must echo the specific origin.
Common server config (Express example)
import cors from 'cors';
app.use(cors({
origin: ['https://app.com', 'https://staging.app.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
}));What CORS does NOT do
- It doesn't stop the request from reaching the server — only the response from being read.
- It doesn't apply to
<img>,<script src>,<link>, or form submissions — those have been cross-origin since forever (one reason CSRF exists). - It doesn't apply server-to-server. Your Node backend can call any URL; CORS is browser-enforced.
Frequent debugging patterns
| Symptom | Cause |
|---|---|
| "blocked by CORS policy: No Access-Control-Allow-Origin" | Server didn't send the header. |
| Works for GET, fails for PUT/DELETE | Server returns CORS headers on the actual response but not on the OPTIONS preflight. |
| "credentials flag is true, but Access-Control-Allow-Origin is *" | Echo the specific origin, don't use * with credentials. |
| Works in Postman/curl, fails in browser | Postman doesn't enforce CORS. The issue is browser-only. |
| OPTIONS returns 404 | Server routing doesn't handle OPTIONS — add a catch-all or use a CORS middleware. |
Beyond CORS
- CORP (Cross-Origin-Resource-Policy): opt-in per resource — "only same-origin can embed this image."
- COEP/COOP: enable
crossOriginIsolated(needed for SharedArrayBuffer, high-resolution timers). - SameSite cookies: complementary CSRF defense, independent of CORS.
Follow-up questions
- •What's the difference between simple and preflighted requests?
- •Why can't Access-Control-Allow-Origin be * with credentials?
- •How does CORS interact with SameSite cookies?
- •What's CORP/COEP/COOP and when do you need them?
Common mistakes
- •Setting Access-Control-Allow-Origin: * with credentials: include — browser rejects it.
- •Forgetting to handle OPTIONS in the server router — preflight 404s, real request never fires.
- •Disabling CORS in the browser/dev tools to 'test' — masks the real issue, breaks in production.
- •Echoing back the Origin header without validating against an allowlist — opens CSRF/data exfiltration.
- •Setting Access-Control-Allow-Origin only on success responses — errors also need it for fetch().catch() to see the body.
- •Thinking CORS prevents the request — it prevents the *response* from being read.
Performance considerations
- •Preflight requests add a roundtrip per origin/method/header combination. Mitigate with Access-Control-Max-Age (cache for hours/days), and design APIs so the common path is simple (POST with text/plain JSON, no custom headers — though this conflicts with proper Content-Type). For high-frequency cross-origin APIs, prefer same-origin (proxy through your own server, or use a subdomain with shared cookies).
Edge cases
- •Redirects: the browser re-runs CORS for each hop; preflighted requests don't allow redirects to a different origin.
- •Opaque responses (mode: 'no-cors'): you can fetch them, but JS sees nothing — useful for cache prewarming.
- •Access-Control-Expose-Headers: by default, only safelisted response headers are readable from JS. Custom headers (X-Total-Count) must be explicitly exposed.
- •Subdomain isolation: app.example.com and api.example.com are different origins; cookies need Domain=.example.com to be shared.
- •Service worker fetches inherit CORS rules — credentials, modes must be set explicitly.
Real-world examples
- •Public APIs (GitHub, Stripe) set Access-Control-Allow-Origin: * — they don't accept cookie credentials so * is safe.
- •Internal APIs (app.company.com → api.company.com) echo specific origins and use credentials.
- •CDNs serving fonts/images need CORP headers so they can be embedded across origins.