Concept
The beginner framing: when a frontend app hosted on one domain tries to call an API on a different domain, the browser needs the API to explicitly say "yes, JavaScript running on that other domain is allowed to read my response", CORS is the mechanism for that explicit permission.
The precise mental model: CORS (Cross-Origin Resource Sharing) is a relaxation of the same-origin policy, governing whether a script can READ the response of a cross-origin request, it is not a mechanism for preventing the request from being SENT. This is the single most important, most commonly misunderstood fact about CORS, and it's directly why CORS configuration alone does not protect against CSRF (covered in CSRF): a form submission or <img> tag doesn't care about CORS at all, because it never tries to read the response, CORS only enters the picture when JavaScript itself (via fetch/XMLHttpRequest) tries to read a cross-origin response's contents.
Confirmed directly in a real browser this session: calling fetch() on a cross-origin URL that doesn't grant permission doesn't produce a descriptive error, it throws a deliberately generic TypeError: Failed to fetch, with no indication in the JS-catchable error of whether the cause was a missing CORS header, a network failure, or an invalid URL. The actual reason is only visible in the browser's own DevTools console as a separate, non-JS-catchable message, a real, deliberate browser design choice: JavaScript is never told why a cross-origin read was blocked, only that it was.
// Confirmed: calling fetch() on a cross-origin resource with no CORS permission, try {
const res = await fetch("https://example.com/");
} catch (e) {
console.log(e.name, e.message);
// TypeError "Failed to fetch", genuinely no more detail available to JS
}
// Compare: fetching a resource that DOES grant CORS permission, const res2 = await fetch("https://api.github.com/users/octocat");
console.log(res2.status); // 200, succeeds, because this API explicitly allows itSimple requests vs. preflighted requests
SIMPLE request (no preflight), sent directly, browser checks the RESPONSE headers after:
Method: GET, HEAD, or POST
Headers: only a small "CORS-safelisted" set (Accept, Accept-Language, Content-Language,
and Content-Type restricted to form-data/text-plain/urlencoded)
PREFLIGHTED request, browser sends an OPTIONS request FIRST, before the real one:
Any other method (PUT, DELETE, PATCH)
Any custom header (Authorization, X-Custom-Header, application/json Content-Type)OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization
--- server must respond BEFORE the real request is ever sent ---
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: authorizationFor anything beyond a "simple" request, which covers the overwhelming majority of real API calls, since JSON bodies and Authorization headers are both extremely common, the browser sends an automatic OPTIONS preflight request first, asking the server "would you allow the actual request I'm about to make?" Only if the server's preflight response explicitly allows the specific method and headers does the browser proceed to send the real request at all.
The credentials + wildcard interaction, a real, spec-enforced restriction
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true // ❌ INVALID combination, browsers reject this outrightThis specific combination is disallowed by the spec and rejected by browsers: a server cannot respond with a wildcard origin (*) while also allowing credentialed requests (cookies, HTTP auth). This exists specifically to prevent an accidentally-permissive API (one that reflexively allows every origin) from also exposing cookie-authenticated, user-specific data to every one of those origins, if credentials are needed, the server must echo back the SPECIFIC requesting origin (Access-Control-Allow-Origin: https://app.example.com, dynamically reflecting the Origin request header after validating it against an allowlist), never a wildcard.
Try It
Predict the outcome before checking the solution.
// api.example.com's CORS config:
// Access-Control-Allow-Origin: https://app.example.com
// A malicious page on evil.com tries:
fetch("https://api.example.com/account/delete", { method: "DELETE", credentials: "include" });Given everything above about CORS only restricting response-READING, does this CORS configuration actually stop the account deletion from happening?
Solution
It depends entirely on what kind of request this is, and this is the crux of the CORS/CSRF distinction. DELETE is not a "simple" method, so this triggers a preflight OPTIONS request first. Since api.example.com's CORS policy only allows https://app.example.com, the preflight response won't grant permission for a request originating from evil.com, and for a preflighted request, the browser refuses to send the actual DELETE request at all if the preflight doesn't authorize it. So in THIS specific case, the account deletion is prevented, but not because CORS stopped a "read," but because the preflight mechanism gates the ACTUAL request for non-simple methods specifically. Contrast this with the earlier CSRF <form method="POST"> and <img src> examples: those are "simple" requests (or not through the fetch/XHR CORS machinery at all), so no preflight ever happens, and the request is sent and processed regardless of any CORS configuration, which is exactly why CORS is not a general CSRF defense, even though it happens to incidentally block this one specific preflighted-DELETE scenario.
Implement It Yourself
Build a minimal CORS-checking middleware, the actual origin-validation logic a framework's CORS package wraps:
const ALLOWED_ORIGINS = new Set(["https://app.example.com", "https://admin.example.com"]);
function corsMiddleware(req, res, next) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); // ECHO the specific origin, never '*' here
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin"); // tells CACHES the response varies by Origin, avoids cache poisoning across origins
}
if (req.method
The mechanism: the server checks the incoming Origin header against an explicit allowlist (never trusting a client-supplied value blindly), and only echoes it back as Access-Control-Allow-Origin if it matches, this is what makes credentialed cross-origin requests from multiple specific known origins possible without ever using an unsafe wildcard. The Vary: Origin header is a easy-to-miss but real detail: without it, a shared HTTP cache (a CDN, a proxy) could cache the response generated for one origin and incorrectly serve that same cached response, including its origin-specific CORS header, to a request from a different origin.
Under the Hood
CORS governs the browser's enforcement of cross-origin reads, which is the client-side half of the same request/response cycle covered structurally in Request Lifecycle (browser → server), the preflight OPTIONS request is a real, distinct HTTP round-trip that happens before the actual request, adding one full request/response cycle of latency for any non-simple cross-origin call. And as established directly in CSRF, CORS is not a CSRF defense on its own, the Try It scenario above shows the one narrow case (preflighted requests specifically) where CORS incidentally blocks a forged cross-origin action, which is a coincidental side effect of the preflight mechanism, not something to rely on as an intentional CSRF defense for simple requests.
Common Mistakes
1. Setting Access-Control-Allow-Origin: * on an endpoint that also needs credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true // ❌ browsers reject this combination outrightThis isn't just bad practice, browsers actively refuse to honor a credentialed request under this exact combination. If credentials are needed, the origin must be dynamically validated against an allowlist and echoed back specifically, never wildcarded.
2. Believing a successful preflight response means the actual request is authorized/authenticated
// Preflight succeeds (200/204) → developer assumes the DELETE request is now "safe"The preflight only confirms the SERVER is willing to accept a cross-origin request of this shape, it says nothing about whether the specific REQUEST that follows is properly authenticated or authorized. CORS configuration and application-level auth/authz are entirely separate concerns that both still need to be correct.
3. Forgetting Vary: Origin when dynamically reflecting the origin
res.setHeader("Access-Control-Allow-Origin", req.headers.origin); // ❌ no Vary: OriginWithout Vary: Origin, an intermediate cache can serve a response (including its origin-specific CORS headers) generated for one origin to a completely different origin's request, a real cache-poisoning-adjacent bug, not just a theoretical concern.
Best Practices
- Never combine
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true, validate the requesting origin against an explicit allowlist and echo back the specific value instead. - Set
Vary: Originon any response whose CORS headers are dynamically generated based on the request'sOrigin, to prevent cache-poisoning across origins. - Remember CORS is a browser-enforced RESPONSE-reading restriction, not a request-blocking one, never rely on it as a CSRF or general request-authorization mechanism; pair it with proper server-side auth/authz checks on every request regardless of origin.
- Expect and budget for preflight latency on any non-simple cross-origin API call (custom headers, JSON bodies, non-GET/POST methods), it's a real extra round-trip, not free.
- Use a maintained CORS middleware/library for production APIs rather than hand-rolling origin-matching logic, to avoid subtle allowlist-matching bugs (e.g., a naive substring check that
evil-app.example.comincorrectly matches against an intended allowlist entry).
Performance Tips
- Preflight requests add one full extra network round-trip before the real request can even be sent, for latency-sensitive APIs, this is a real, measurable cost, not a rounding error, especially over slower/higher-latency connections.
- The
Access-Control-Max-Ageresponse header lets the browser CACHE a preflight result for a specified duration, avoiding a repeatedOPTIONSround-trip on every subsequent request to the same origin/method/headers combination, a genuine, easy performance win for frequently-called cross-origin endpoints.
