How CORS protects cross-origin requests
How browsers enforce the same-origin policy, when preflight OPTIONS requests trigger, how Access-Control headers work, and why credentials mode changes everything.
The Interview Question
Interviewer: "A frontend engineer on your team deploys a React app to
app.example.comthat fetches data from your API atapi.example.com. The fetch calls fail in the browser with a CORS error, but the same requests work perfectly from Postman. Walk me through what is happening and why the browser blocks it."
This question tests whether you understand that CORS is a browser-enforced mechanism, not a server-side firewall. The interviewer wants to hear you explain the same-origin policy, how preflight requests work, which response headers the server must set, and why tools like Postman bypass the entire mechanism. Candidates who just say "add Access-Control-Allow-Origin: *" miss the depth.
What to Clarify Before Answering
You: "Before I dive in, let me scope this..."
- "Are the frontend and API on different subdomains of the same root domain, or completely different domains? Subdomains still count as different origins."
- "Is the frontend sending simple GET requests, or does it include custom headers like
Authorization? Custom headers trigger preflight." - "Does the API need to accept cookies or auth tokens in the request? That changes the wildcard rules significantly."
- "Is there a reverse proxy or API gateway in front of the API? Sometimes the proxy strips or overwrites CORS headers."
- "Are we seeing the error on all requests or only on non-GET methods like PUT or DELETE?"
Why this matters: CORS behavior changes dramatically based on whether credentials are involved, what HTTP methods are used, and which headers are sent. A candidate who asks these questions shows they understand CORS is not a single mechanism but a set of rules that vary by request type.
The 30-Second Answer
CORS (Cross-Origin Resource Sharing) is a browser-enforced security protocol that prevents JavaScript on one origin from reading responses from a different origin. An origin is the combination of scheme + host + port (so https://app.example.com and https://api.example.com are different origins). When JavaScript makes a cross-origin request, the browser checks whether the request is "simple" (GET/POST with standard headers) or requires a preflight OPTIONS request. For preflighted requests, the browser sends an OPTIONS request first to ask the server "do you allow this method and these headers from this origin?" The server responds with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. Only if the server's response matches the request does the browser send the actual request. When credentials (cookies, auth headers) are involved, the server cannot use wildcards and must echo the exact origin.
The Architecture Overview
The browser sits between your JavaScript and the network. When JavaScript calls fetch() to a different origin, the CORS engine intercepts the request. For simple requests, it sends the request directly but checks the response headers before exposing the data to JavaScript. For preflighted requests, it sends an OPTIONS request first, validates the server's permissions, and only then sends the actual request.
The critical insight is that the server always receives and processes the request. CORS does not prevent the request from reaching the server. It prevents the browser from exposing the response to JavaScript. This is why Postman works fine: Postman is not a browser and does not enforce the same-origin policy.
Common misconception
CORS is not a server-side security mechanism. It does not protect your API from malicious requests. Any HTTP client (curl, Postman, server-to-server calls) bypasses CORS entirely. CORS protects users by preventing malicious websites from reading responses from APIs the user is authenticated with.
Same-Origin Policy: The Foundation
The same-origin policy is the oldest and most fundamental browser security mechanism. It was introduced in Netscape Navigator 2.0 in 1995, and every browser since has enforced it.
An origin is defined as the tuple of three components:
| Component | Example A | Example B | Same Origin? |
|---|---|---|---|
| Scheme | https | http | No |
| Host | app.example.com | api.example.com | No |
| Port | :443 | :8443 | No |
| All three | https://app.example.com:443 | https://app.example.com:443 | Yes |
All three components must match exactly. There are no exceptions. Even http vs https on the same host is a different origin. Even example.com vs www.example.com is a different origin.
Why does the same-origin policy exist? Without it, any website you visit could silently make requests to your bank, your email, or any other site where you are logged in, and read the responses. Your browser sends cookies automatically with every request to a domain. The same-origin policy ensures that evil.com cannot call bank.com/api/balance and read your balance, even though your browser would happily attach your bank cookies to that request.
What the same-origin policy allows
The policy restricts reading responses, not sending requests. Your browser will send a cross-origin request (the server receives it). The policy prevents JavaScript from reading what comes back. This distinction matters because some attacks (like CSRF) exploit the fact that the request is sent regardless.
CORS is the controlled relaxation of this policy. It lets a server explicitly opt in to sharing its responses with specific origins.
Simple Requests vs Preflighted Requests
Not all cross-origin requests are treated the same. The browser classifies each request as either "simple" or "preflighted" based on the method, headers, and content type.
Simple requests
A request is "simple" (the spec calls it a request that does not trigger a preflight) if it meets ALL of these conditions:
- Method: GET, HEAD, or POST only
- Headers: Only CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type, Range)
- Content-Type (if POST): Only
application/x-www-form-urlencoded,multipart/form-data, ortext/plain - No ReadableStream body
- No event listeners on XMLHttpRequest.upload
For simple requests, the browser sends the request directly with an Origin header and checks the Access-Control-Allow-Origin response header after the response arrives.
Preflighted requests
Any request that does not meet the simple request criteria triggers a preflight. Common triggers:
- Using PUT, DELETE, or PATCH methods
- Sending an
Authorizationheader - Sending
Content-Type: application/json - Sending any custom header (like
X-Request-Id)
The JSON trap
Almost every modern API sends Content-Type: application/json. This single header turns every POST request into a preflighted request. If you are seeing unexpected OPTIONS requests in your server logs, this is almost certainly why.
I find this classification the most confusing part of CORS for newcomers. The reason for the distinction is historical: HTML forms have always been able to make cross-origin POST requests with form-encoded data. The browser could not break that behavior. So "simple" requests match what HTML forms could already do, and everything else (anything JavaScript can do that forms cannot) requires explicit server permission via preflight.
The Preflight OPTIONS Request
When the browser determines a preflight is needed, it sends an OPTIONS request to the same URL as the actual request. This OPTIONS request contains two special headers:
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
Access-Control-Request-Method: The HTTP method the actual request will useAccess-Control-Request-Headers: The non-simple headers the actual request will include
The server must respond with the corresponding Access-Control-Allow-* headers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
The browser compares the request's method and headers against the server's allowed list. If the method is in Allow-Methods and all headers are in Allow-Headers, the browser proceeds with the actual request. If anything does not match, the browser blocks the request and logs a CORS error.
Preflight caching with Max-Age
Access-Control-Max-Age tells the browser how long (in seconds) to cache the preflight result. During this window, the browser skips the OPTIONS request for identical cross-origin requests. This is critical for performance because preflight adds a full round trip to every non-simple request.
Response Headers: The Server's Permission System
The server communicates its CORS policy entirely through response headers. I will walk through each one.
Access-Control-Allow-Origin
The most important header. Tells the browser which origins can read the response.
| Value | Meaning | When to use |
|---|---|---|
* | Any origin | Public APIs with no credentials |
https://app.example.com | Exact origin | Private APIs, credentials required |
| (absent) | No cross-origin access | Default, most secure |
The server can only return one origin (or *). You cannot return a comma-separated list. If you need to allow multiple origins, the server must check the Origin request header against an allowlist and echo back the matching origin dynamically.
// Pseudocode: dynamic origin allowlist
allowed_origins = ["https://app.example.com", "https://staging.example.com"]
request_origin = request.headers["Origin"]
if request_origin in allowed_origins:
response.headers["Access-Control-Allow-Origin"] = request_origin
response.headers["Vary"] = "Origin" // Critical for caching
Always set Vary: Origin with dynamic origins
If your server echoes back different Access-Control-Allow-Origin values depending on the request's Origin header, you must include Vary: Origin in the response. Without it, a CDN or browser cache might serve a response with Allow-Origin: https://app.example.com to a request from https://other.example.com, causing a CORS failure.
Access-Control-Allow-Methods
Lists which HTTP methods the server accepts for cross-origin requests. Only needed in preflight responses.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers
Lists which request headers the server accepts. Only needed in preflight responses.
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Expose-Headers
By default, JavaScript can only read six "CORS-safelisted" response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, and Pragma. To expose additional headers (like a custom X-Total-Count for pagination), the server must explicitly list them.
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id, ETag
Access-Control-Allow-Credentials
Controls whether the browser includes cookies and authorization headers in cross-origin requests. I will cover this in depth in the next section because it changes everything.
Credentials Mode: Where Everything Gets Complicated
Credentials mode is the single most confusing aspect of CORS, and the source of most production CORS bugs I have debugged. When JavaScript sets credentials: 'include' on a fetch request, three rules activate simultaneously:
fetch('https://api.example.com/data', {
credentials: 'include' // Send cookies and auth headers
})
Rule 1: The server MUST respond with Access-Control-Allow-Credentials: true.
Rule 2: The server MUST NOT use * for Access-Control-Allow-Origin. It must echo the exact origin.
Rule 3: The server MUST NOT use * for Access-Control-Allow-Headers or Access-Control-Allow-Methods. It must list them explicitly.
Why credentials mode exists
Without credentials mode, cross-origin requests do not include cookies. This is intentional and safe. The moment you enable credentials, you are telling the browser "yes, I want my cookies sent to this other origin." That is why the restrictions tighten: the server must explicitly acknowledge the specific origin because attaching cookies to a wildcarded origin would expose user sessions to any website.
What Happens When Things Break
CORS errors are notoriously unhelpful. The browser's error messages are deliberately vague (to prevent information leakage to malicious scripts). I use the following debugging flowchart when diagnosing CORS issues.
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
Missing Allow-Origin header | Browser blocks response, JS gets TypeError | Console: "No 'Access-Control-Allow-Origin' header" | Add CORS middleware to server |
| Preflight returns 405 | Server does not handle OPTIONS method | Console: "Response to preflight has invalid HTTP status code 405" | Add OPTIONS handler or configure framework CORS |
| Wildcard with credentials | Browser rejects response | Console: "Cannot use wildcard... when credentials flag is true" | Echo exact origin instead of * |
Missing Vary: Origin | CDN caches wrong origin header | Intermittent failures depending on cache state | Add Vary: Origin to all CORS responses |
| Redirect on preflight | Browser aborts on 3xx OPTIONS | Console: "Redirect is not allowed for a preflight request" | Ensure the URL in fetch matches the final URL (no redirects) |
| Opaque response in Service Worker | fetch() in no-cors mode returns opaque response | response.type === 'opaque', body is null | Switch to cors mode or use server-side proxy |
The redirect trap
If your server redirects the preflight OPTIONS request (for example, HTTP to HTTPS redirect, or a path normalization redirect), the browser aborts the entire CORS flow. The actual request never fires. This is one of the hardest CORS bugs to diagnose because the server logs show a successful redirect, but the browser sees a failure.
Proxy Patterns to Avoid CORS
Sometimes the cleanest solution is to avoid cross-origin requests entirely by routing through a proxy. I use three patterns depending on the deployment architecture.
Same-origin API proxy
Deploy your frontend and API behind the same origin using a reverse proxy.
// nginx.conf
server {
server_name app.example.com;
location /api/ {
proxy_pass http://backend:3001/;
}
location / {
proxy_pass http://frontend:3000/;
}
}
Now the frontend fetches /api/data (same origin), and Nginx forwards to the backend. No CORS needed.
Next.js / framework rewrites
Most frontend frameworks support path rewrites that proxy API calls through the dev server.
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'https://api.example.com/:path*',
},
]
},
}
Edge function proxy
For production deployments where you cannot colocate frontend and backend, use an edge function (Cloudflare Workers, Vercel Edge Functions) as a same-origin proxy.
// Cloudflare Worker as CORS proxy
export default {
async fetch(request) {
const url = new URL(request.url)
// Rewrite /api/* to the backend origin
url.hostname = 'api.example.com'
const response = await fetch(url.toString(), {
method: request.method,
headers: request.headers,
body: request.body,
})
return response
}
}
This keeps the API call same-origin from the browser's perspective (the edge function and frontend share a domain) while forwarding to the actual backend server-to-server (no CORS applies).
The Full Request Lifecycle
Putting it all together, here is the complete lifecycle of a cross-origin credentialed request from start to finish. Each step is a potential failure point.
- JavaScript calls
fetch()withcredentials: 'include'andContent-Type: application/json - Browser detects cross-origin (different scheme, host, or port)
- Browser classifies as preflighted (JSON content type triggers preflight)
- Browser checks preflight cache (if
Max-Agewas set previously and has not expired, skip to step 7) - Browser sends OPTIONS with
Access-Control-Request-MethodandAccess-Control-Request-Headers - Server responds with Access-Control-Allow-* headers. Browser validates method and headers are allowed.
- Browser sends actual request with
Originheader and cookies attached - Server processes request and includes
Access-Control-Allow-Origin(exact origin),Access-Control-Allow-Credentials: true, andVary: Originin response - Browser validates response headers against credentials mode rules (no wildcards)
- Response exposed to JavaScript (or blocked with CORS error if any check fails)
If any step fails, the browser logs a CORS error and the JavaScript receives an opaque response with no data. The server still processed step 8, which means CORS errors can mask successful server-side mutations. This is why I always recommend checking server logs alongside browser console errors when debugging CORS issues.
CORS in Common Frameworks
Getting CORS right depends on your server framework. Here is how the major frameworks handle it.
Express (Node.js)
const cors = require('cors')
const app = express()
// Production: explicit allowlist
app.use(cors({
origin: ['https://app.example.com', 'https://staging.example.com'],
CORS interacts with another browser security mechanism that often causes confusion: the **SameSite cookie attribute**. SameSite controls when cookies are sent, while CORS controls when responses are readable. They are independent but both affect cross-origin behavior.
| SameSite Value | Cookie sent cross-origin? | CORS still required? |
|:---:|:---:|:---:|
| `None` (+ Secure) | Yes | Yes (for response access) |
| `Lax` | Only on top-level GET navigation | Yes (fetch/XHR never gets cookie) |
| `Strict` | Never | Yes (but no cookie regardless) |
If your cookies are `SameSite=Lax` (the default in modern browsers), cross-origin `fetch()` calls will never include cookies regardless of CORS credentials mode. You need `SameSite=None; Secure` for cookies to be sent cross-origin, AND the server must set CORS credentials headers. Both mechanisms must agree.
credentials: true,
maxAge: 7200,
exposedHeaders: ['X-Request-Id', 'X-Total-Count'],
}))
Spring Boot (Java)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true)
.maxAge(7200);
}
}
Django (Python)
# settings.py
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
"https://staging.example.com",
]
CORS_ALLOW_CREDENTIALS = True
CORS_PREFLIGHT_MAX_AGE = 7200
API Gateway (AWS)
In AWS API Gateway, CORS configuration is declarative. The gateway generates the OPTIONS responses automatically. I find this the most common source of CORS issues in serverless architectures because the gateway configuration and the Lambda response headers must both be correct. If your Lambda returns a 500 error without CORS headers, the browser shows a CORS error instead of the actual error message.
Error responses need CORS headers too
A subtle but critical detail: CORS headers must be present on error responses (4xx, 5xx), not just success responses. If your API returns a 401 Unauthorized without Access-Control-Allow-Origin, the browser shows "CORS error" instead of "Unauthorized." The developer sees a CORS problem when the actual problem is authentication. Always set CORS headers in middleware that runs before your error handlers.
Performance Characteristics
| Aspect | Simple Request | Preflighted Request | Preflighted (Cached) |
|---|---|---|---|
| HTTP round trips | 1 | 2 (OPTIONS + actual) | 1 (cache hit) |
| Added latency | ~0ms | 50-200ms (one RTT) | ~0ms |
| Server load | Normal | 2x requests | Normal |
| Cache duration | N/A | 0-86400s | Configurable |
The performance impact of CORS is rarely significant for most applications. The exception is APIs that serve many small requests (like autocomplete or real-time search) where the preflight overhead per request adds up. For these cases, I recommend either using a same-origin proxy or ensuring Max-Age is set to the maximum value your browser supports.
| Browser | Max Max-Age Cache | Default (no Max-Age) |
|---|---|---|
| Chrome / Chromium | 7200s (2 hours) | 5s |
| Firefox | 86400s (24 hours) | 5s |
| Safari | 86400s (24 hours) | 5s |
How This Compares to Alternatives
| Approach | Mechanism | Credentials | Browser Enforced | Complexity |
|---|---|---|---|---|
| CORS | HTTP headers | Supported with restrictions | Yes | Medium |
| JSONP | Script tag injection | Cookies sent automatically | No | Low (but insecure) |
| Same-origin proxy | Reverse proxy | Full cookie support | N/A (same origin) | Low |
| PostMessage | Window messaging API | Manual transfer | Partially | High |
| Server-to-server | Backend proxy | N/A | No | Medium |
JSONP was the pre-CORS hack for cross-origin data. It works by injecting a <script> tag (scripts are not subject to same-origin policy) that calls a callback function with the data. I never recommend JSONP: it only supports GET, has no error handling, and is an XSS vector because you are executing arbitrary JavaScript from another origin.
For most teams I work with, the answer is: use CORS for public APIs, use a same-origin proxy for private APIs, and use server-to-server calls when the browser should not be involved at all.
Interview Cheat Sheet
- When asked "what is CORS?": "CORS is a browser mechanism that relaxes the same-origin policy. It lets a server declare which foreign origins can read its responses via HTTP headers. It is enforced by the browser, not the server."
- When asked "why does Postman work but the browser doesn't?": "Postman is not a browser. It does not enforce the same-origin policy. CORS only applies to JavaScript running in a browser sandbox."
- When asked about preflight: "Preflight is an OPTIONS request the browser sends before the actual request when the request uses non-simple methods (PUT, DELETE) or custom headers (Authorization, Content-Type: application/json). The server must respond with Access-Control-Allow-* headers."
- When asked about credentials: "When credentials mode is enabled, the server must echo the exact origin (not wildcard), set Allow-Credentials: true, and explicitly list methods and headers."
- When asked 'how do I fix CORS errors?': "First: identify if it is a preflight issue (missing OPTIONS handler), a missing Allow-Origin header, or a credentials/wildcard conflict. Second: decide if CORS is the right approach or if a same-origin proxy would be simpler."
- When asked about security: "CORS does not protect your API. It protects users. A malicious server-side client can always call your API. CORS prevents a malicious website from reading your API responses using the user's cookies."
- When asked about performance: "Preflight adds one RTT per unique request. Mitigate with Access-Control-Max-Age (up to 2 hours in Chrome, 24 hours in Firefox) or eliminate CORS entirely with a same-origin proxy."
- When asked about
Access-Control-Allow-Origin: *: "Wildcard is fine for truly public APIs with no credentials. The moment you need cookies or Authorization headers, you must echo the exact origin."
Test Your Understanding
Quick Recap
- An origin is the combination of scheme, host, and port. Any difference makes two URLs cross-origin.
- The same-origin policy prevents JavaScript from reading responses from a different origin. CORS relaxes this policy through HTTP headers.
- Simple requests (GET/HEAD/POST with standard headers) skip preflight. Everything else triggers an OPTIONS preflight first.
- The server communicates permissions through
Access-Control-Allow-Origin,Allow-Methods,Allow-Headers,Max-Age,Expose-Headers, andAllow-Credentials. - Credentials mode forbids wildcards. The server must echo the exact origin and explicitly list allowed methods and headers.
- Max-Age caches preflight results and is the primary knob for reducing CORS performance overhead.
- A same-origin proxy eliminates CORS entirely and is the simplest solution for most private APIs.
- CORS protects the user, not the server. Server-side clients bypass it completely.
Related Concepts
- Content Security Policy (CSP): Controls which resources the browser can load on a page (scripts, styles, images). Complementary to CORS, which controls cross-origin reads.
- CSRF (Cross-Site Request Forgery): Exploits the fact that browsers send cookies automatically. CORS mitigates some CSRF vectors by blocking response reads, but dedicated CSRF tokens are still needed for state-changing requests.
- OAuth 2.0 Token Flow: When using token-based auth instead of cookies, CORS credential restrictions do not apply. The token goes in the
Authorizationheader, which triggers preflight but avoids the wildcard restrictions. - HTTP/2 and HTTP/3: The transport layer does not change CORS behavior. Preflight is still a separate HTTP request regardless of protocol version.
- Service Workers: Can intercept and modify CORS requests/responses, but are themselves subject to CORS rules when fetching cross-origin resources.