CORS Explained: Cross-Origin Resource Sharing

CORS is best understood as a browser read-permission protocol. It sits on top of HTTP and the same-origin policy. It does not make an API private; it decides whether a browser should expose a cross-origin response to the JavaScript running inside a page.
Quick Navigation: The Core Mental Model ⢠Simple Requests ⢠Preflight Requests ⢠Credentials and Cookies ⢠Headers You Must Understand ⢠Server Implementation Patterns ⢠Debugging CORS ⢠CORS vs Security
The Core Mental Model
Same-Origin Policy First
Browsers isolate scripts by origin. JavaScript loaded from https://app.example.com should not freely read responses from https://bank.example, https://api.other.com, or even http://app.example.com.
CORS is the server's way to say: this specific origin may read this response.
Read Access, Not Network Access
A common misunderstanding is: "CORS blocks the request."
More precise:
That is why CORS is not a replacement for server-side security.
Figure 1: CORS Boundary
Page origin: https://app.example.com
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Browser tab ā
ā ā
ā JavaScript fetch() ā
ā ā ā
ā ā request + Origin ā
āāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāā
ā
v
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā API server ā
ā https://api.example.com ā
ā ā
ā Responds with: ā
ā Access-Control-Allow-Origin ā
āāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāā
ā
v
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Browser CORS check ā
ā ā
ā Allowed? JS can read. ā
ā Denied? JS cannot read. ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāSenior phrasing: CORS is the browser asking, "Is this response allowed to enter this page's JavaScript world?"
Simple Requests
What Happens Without Preflight
Some cross-origin requests do not need a preflight. Historically, these resemble what HTML forms could already send cross-site.
A request is usually simple when it uses:
GET, HEAD, or POSTContent-Type is set, one of: - application/x-www-form-urlencoded
- multipart/form-data
- text/plain
Example:
fetch('https://api.example.com/products')The browser sends:
GET /products HTTP/1.1
Host: api.example.com
Origin: https://app.example.comThe server allows the response to be read:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/jsonFigure 2: Simple CORS Flow
Browser JS API server
ā ā
ā GET /products ā
ā Origin: app.example.com ā
āāāāāāāāāāāāāāāāāāāāāāāāāā>ā
ā ā
ā 200 OK ā
ā Allow-Origin: app ā
ā<āāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā ā
ā Browser exposes response ā
ā to JavaScript āImportant nuance: simple does not mean safe. It only means the browser does not ask a preflight question first. The server must still protect state-changing behavior.
Preflight Requests
Why Preflight Exists
For requests that go beyond the safelisted shape, the browser first sends an OPTIONS request. This is called a preflight.
Common triggers:
PUT, PATCH, or DELETEContent-Type: application/jsonAuthorization or X-Request-IDExample frontend request:
fetch('https://api.example.com/orders/123', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer access-token'
},
body: JSON.stringify({ status: 'cancelled' })
})Before the PATCH, the browser asks:
OPTIONS /orders/123 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-typeThe server approves:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Vary: OriginThen the browser sends the actual request.
Figure 3: Preflight Flow
Browser JS API server
ā ā
ā OPTIONS /orders/123 ā
ā Origin: app.example.com ā
ā Request-Method: PATCH ā
ā Request-Headers: auth... ā
āāāāāāāāāāāāāāāāāāāāāāāāāā>ā
ā ā
ā 204 No Content ā
ā Allow-Origin: app ā
ā Allow-Methods: PATCH ā
ā Allow-Headers: auth... ā
ā<āāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā ā
ā PATCH /orders/123 ā
ā Origin: app.example.com ā
āāāāāāāāāāāāāāāāāāāāāāāāāā>ā
ā ā
ā 200 OK + CORS headers ā
ā<āāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā ā
ā Browser exposes response āInterview nuance: the actual request does not include Access-Control-Request-* headers. Those belong to the preflight request only.
Credentials and Cookies
Credentials Are Opt-In
Cross-origin requests do not automatically expose credentialed responses to JavaScript. Both sides need to opt in.
Client:
fetch('https://api.example.com/me', {
credentials: 'include'
})Server:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: trueCritical rule:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueThat combination is invalid for credentialed browser reads. With credentials, the allowed origin must be explicit.
Cookies Still Have Cookie Rules
CORS does not override cookie policy. Cross-site cookies may also need:
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Noneor a same-site design where SameSite=Lax is enough.
CORS and CSRF Are Different
CORS decides whether JavaScript can read a cross-origin response.
CSRF is about unwanted state-changing requests sent with ambient credentials such as cookies.
A server can be vulnerable to CSRF even if CORS is strict, because a simple form-like request may still reach the server. CORS is not a CSRF defense by itself.
Headers You Must Understand
| Header | Sent By | Purpose | Interview Notes |
|---|---|---|---|
Origin | Browser request | Tells the server which origin initiated the request | Present on CORS requests and many same-site state-changing requests |
Access-Control-Allow-Origin | Server response | Says which origin may read the response | Use exact origins for private APIs; * is only for public non-credentialed reads |
Access-Control-Allow-Methods | Server preflight response | Lists methods allowed for the actual request | Needed for preflight approval |
Access-Control-Allow-Headers | Server preflight response | Lists request headers allowed for the actual request | Must include custom headers such as Authorization |
Access-Control-Allow-Credentials | Server response | Allows credentialed browser reads when client opts in | Must be true; cannot pair with wildcard origin |
Access-Control-Expose-Headers | Server response | Lets JS read non-safelisted response headers | Needed for headers like X-Request-ID or some pagination headers |
Access-Control-Max-Age | Server preflight response | Caches preflight permission | Browser caps may limit the effective duration |
Vary: Origin | Server response | Tells caches the response varies by request origin | Important when reflecting allowed origins dynamically |
Access-Control-Expose-Headers: X-Total-Count, X-Request-IDThen JavaScript can read it:
const response = await fetch(url)
console.log(response.headers.get('X-Total-Count'))Server Implementation Patterns
Pattern 1: Public Read-Only API
Use this only when the response is intentionally public and no credentials are involved.
Access-Control-Allow-Origin: *Good for public metadata, public assets, or open read APIs.
Pattern 2: Private Browser App API
Use an allowlist. Reflect only trusted origins.
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com',
'http://localhost:3000'
])
function cors(req, res, next) {
const origin = req.headers.origin
if (allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-ID')
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Max-Age', '600')
if (req.method === 'OPTIONS') {
return res.status(204).end()
}
next()
}Pattern 3: Framework Middleware
Using package middleware is fine, but configure it intentionally:
app.use(cors({
origin(origin, callback) {
if (!origin) return callback(null, false)
callback(null, allowedOrigins.has(origin))
},
credentials: true,
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Request-ID']
}))Avoid blindly reflecting any origin. Access-Control-Allow-Origin: req.headers.origin without validation is effectively public CORS with extra risk.
Debugging CORS
Read the Browser Console First
JavaScript usually receives a generic failure. The browser console explains the real CORS reason.
Debugging Checklist
1. Confirm the request origin.
Origin: https://app.example.com2. Confirm the API response includes the expected allow origin.
Access-Control-Allow-Origin: https://app.example.com3. If there is a preflight, inspect the OPTIONS response.
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: authorization, content-type4. If credentials are used, verify all three pieces:
fetch(url, { credentials: 'include' })Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com5. Check that the route handles OPTIONS without requiring application auth that only the actual request has.
6. Check redirects. Preflight plus redirects can behave differently across browsers and infrastructure.
7. Check CDN/proxy caching. If the server reflects origins, include Vary: Origin.
Do Not Fix CORS From the Frontend
These are usually not real fixes:
fetch(url, { mode: 'no-cors' })no-cors gives you an opaque response that JavaScript cannot meaningfully read. It hides the error; it does not solve API access.
Browser extensions and dev proxies may help local debugging, but production CORS must be fixed at the server or gateway that serves the response.
CORS vs Security
What CORS Protects
CORS protects browser users by limiting what one origin's JavaScript can read from another origin.
It helps prevent a malicious page from silently reading sensitive cross-origin responses using the victim's browser context.
What CORS Does Not Protect
CORS does not:
Common Bad Configurations
Reflecting Every Origin
res.setHeader('Access-Control-Allow-Origin', req.headers.origin)This is unsafe unless origin was checked against a trusted allowlist.
Combining Credentials With Broad Access
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://any-origin-from-request.exampleIf the origin is not validated, any site can read credentialed responses.
Treating CORS Errors as Auth Errors
A CORS failure means the browser refused to expose the response. The request may have failed auth, failed preflight, hit a proxy, missed a header, or been blocked after a redirect. Inspect the network panel.
Interview Answer
| Question | Good Answer |
|---|---|
| Why does Postman work but browser fails? | Postman is not enforcing browser CORS. CORS is a browser security model. |
Why did adding Authorization trigger OPTIONS? | Authorization is not a safelisted request header, so the browser preflights. |
Can I use * in production? | Yes for truly public non-credentialed responses; not for credentialed private APIs. |
| Why is my custom response header missing? | The server may need Access-Control-Expose-Headers. |
| Is CORS a CSRF defense? | No. CORS controls JS read access; CSRF is about unwanted credentialed state changes. |
Key Takeaways
Vary: Origin when origins are reflected.