← Back to Logs

How CORS Actually Works

Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)

CORS is one of those mechanisms that developers usually encounter through frustration. A frontend in Lisbon calls an API in Frankfurt, the browser throws a red error, and someone adds Access-Control-Allow-Origin: * until the noise stops. That sequence is why so many systems end up with cargo cult CORS configurations. People remember the header name. They do not build a mental model of what the browser is trying to protect.

The model starts with a small but important correction. CORS is not about whether one server may talk to another server. It is not about whether a TCP connection can be opened. It is not a substitute for authentication, and it is not a protection against curl, mobile apps, backend jobs, or any other non browser client. CORS is a browser enforcement rule for when JavaScript running on one origin is allowed to read a response from another origin.

That sounds narrower than many people expect. It is narrower. It is also why CORS bugs are subtle. The request may still go out. Cookies may still be sent. The server may still process the action. The only thing the browser withholds is the script's ability to inspect the response, unless the server opts in with the right headers.

This article builds the mechanism from first principles. We will start with the same origin policy, then walk through simple requests, preflights, credentialed cross origin calls, response header handling, cache interaction, and the most common misconfigurations. After that we will look at what CORS does not solve, how it relates to CSRF, and what a production policy should look like when you have a real SPA, a real API, and a CDN in front of them.

Start With The Origin Tuple

An origin in the browser security model is the triple of scheme, host, and port.

  • https://app.example.eu is one origin.
  • https://api.example.eu is a different origin because the host differs.
  • http://app.example.eu is a different origin because the scheme differs.
  • https://app.example.eu:8443 is a different origin because the port differs.

That seems pedantic until you look at what the browser is defending. A page loaded from one origin gets ambient authority over the user's interaction with that origin. It can read the DOM, local storage, IndexedDB, same origin cookies that are not HttpOnly, service worker controlled responses, and whatever authenticated API responses its own origin returns to it. If any random page could read responses from every other origin, the tab you opened for a recipe site could silently read your bank account JSON, your webmail HTML, or the admin panel of a router on your local network.

The same origin policy is the default barrier that prevents that. It is older than CORS and broader than CORS. It governs DOM access, storage access, canvas tainting, and many other browser capabilities. CORS is the HTTP layer carve out that lets one origin selectively relax part of that barrier for scripted reads.

The important word there is selectively. Browsers have always allowed some kinds of cross origin interaction for compatibility. A page may link to another site. It may embed an image, stylesheet, script, or font from another origin, subject to the resource type's own rules. A form may submit to another origin. An img tag may request bytes from a CDN in Amsterdam without the page learning anything about that response except whether the load broadly succeeded. The web would not function if every cross origin request were forbidden.

What the same origin policy blocks by default is active inspection across origins. A script on https://shop.example.eu cannot simply call fetch('https://bank.example.eu/api/balance') and then read the JSON. CORS is the standard way for bank.example.eu to say “for this route, for these origins, for this kind of request, I permit that read”.

CORS Is A Read Permission System, Not A Network Firewall

This is the part that trips people up.

Suppose JavaScript on https://app.athens.example runs:

fetch('https://api.berlin.example/customers')

If the API sends no CORS headers, two things can both be true.

  1. the HTTP request leaves the browser and reaches api.berlin.example
  2. the browser refuses to expose the response body to the calling script

From the script's point of view, fetch() rejects with a network style error. From the server's point of view, it may have received the request normally, authenticated it, and generated a perfectly valid response. CORS sits between the network stack and the JavaScript API surface. It is not a server side access control engine.

That distinction matters in three operational areas.

First, logs. If you see blocked CORS errors in the browser console, the server may still show a full stream of requests. Teams sometimes assume “the browser blocked it” means “the request never reached us”. That is wrong for many CORS failures.

Second, side effects. A blocked cross origin read does not imply a blocked state change. If the request was a simple form compatible POST and the user's cookies were sent, the server may have created the order, changed the password, or submitted the support ticket. The script simply cannot read the response. This is why CORS is not a CSRF defence.

Third, attackers. Non browser clients do not care about CORS at all. curl, Postman, server side scripts, and mobile apps can call your API directly. If your server trusts Origin as though it were an authentication factor, an attacker will remove the browser from the equation and talk to the endpoint directly.

There is one more source of confusion here: mode: 'no-cors'. Developers occasionally discover it in fetch() and assume it disables CORS. It does not. It tells the browser to make a heavily restricted request and return an opaque response that the script cannot inspect. That mode exists for legacy resource fetches such as beacons and service worker flows, not for bypassing browser policy. If you set mode: 'no-cors', you get fewer capabilities, not more.

Why Some Cross Origin Requests Skip Preflight

Not every CORS request begins with an OPTIONS preflight. Browsers distinguish between simple requests and non simple requests.

A request is broadly “simple” when it uses one of the historically form compatible methods and headers:

  • method is GET, HEAD, or POST
  • author controlled headers stay within the safelisted set
  • Content-Type, if explicitly set, is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain

The reason is historical compatibility. Before XHR, before fetch(), and long before SPAs, ordinary HTML forms could already submit cross origin GET and POST requests. Browsers could not retroactively break that part of the web. So the CORS model says, in effect: requests that look like the old web may still be sent directly, but the response is readable to script only if the target origin explicitly permits it.

That is why this request usually does not preflight:

fetch('https://api.example.eu/search?q=ssd')

And this one usually does:

fetch('https://api.example.eu/orders', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ sku: 'NVME-2TB', quantity: 1 }),
})

The JSON content type is not safelisted. The Authorization header is not safelisted. The browser therefore asks permission first.

This is also why frontend teams sometimes “fix” a preflight by downgrading a JSON request into a form encoded request or by moving data into query parameters. The preflight disappears, but the security model has not become simpler. The request merely moved back into the historically compatible bucket, where the browser may send it directly and enforce CORS only at the response read stage.

Preflight is therefore not a tax the browser invented randomly. It is the browser noticing that a script wants to send a request shape with more reach than an old fashioned form submission and asking the server to opt in explicitly.

What A Preflight Actually Looks Like On The Wire

Take a frontend on https://app.prague.example that wants to send JSON with an Authorization header to https://api.prague.example/v1/invoices.

The browser sends an OPTIONS request first.

OPTIONS /v1/invoices HTTP/1.1
Origin: https://app.prague.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

That request asks a narrow question: if I later send a POST with these non safelisted headers from this origin, do you allow it?

A permitting response might look like this.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.prague.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Vary: Origin

If the browser accepts that response, it then sends the real request.

POST /v1/invoices HTTP/1.1
Origin: https://app.prague.example
Authorization: Bearer eyJ...
Content-Type: application/json
 
{"customerId":"c_1842","amountCents":129900}

And the actual response still needs CORS headers if the script is going to read it.

HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.prague.example
Vary: Origin
Content-Type: application/json
 
{"id":"inv_88421","status":"queued"}

A few important points hide inside this exchange.

The preflight response is not “authentication”. It is capability advertisement. The browser is the component that decides whether to continue.

Access-Control-Max-Age tells the browser how long it may cache the positive preflight decision. This avoids doubling round trips for every non simple request. Browsers impose their own upper bounds, so an enormous value does not necessarily mean an enormous cache lifetime in practice.

The cache key is more specific than many developers think. Browsers consider at least the origin, target URL, method, and requested headers. A cached preflight for POST with authorization, content-type does not automatically bless an unrelated DELETE with different headers.

Also note that the preflight is a browser behaviour. Your mobile SDK, your Go backend, and your Python job runner do not send it unless you implement it yourself. CORS lives entirely in browser space.

Credentials Change The Risk Model

Credentialed CORS is where harmless confusion turns into real exposure.

In the Fetch model, credentials: 'include' tells the browser to include cookies, HTTP authentication, and client certificates on a cross origin request when the surrounding cookie policy allows it. If the target site uses session cookies, this is the switch that turns a public cross origin read into an authenticated cross origin read.

Example:

fetch('https://api.example.eu/account', {
  credentials: 'include',
})

If the user is logged in to api.example.eu and the relevant cookies are eligible to be sent, the browser may attach them. Now the server is processing the request in the context of a real user session.

This is why the wildcard rule exists. If a response says:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

browsers reject it. A wildcard means “any origin may read this response”. Credentials mean “this response may contain per user private data”. Putting those two together would turn every website on the internet into an authenticated reader of your API.

The correct credentialed pattern is to echo a specific allowed origin and send Access-Control-Allow-Credentials: true only when that origin is in your allowlist.

Access-Control-Allow-Origin: https://app.example.eu
Access-Control-Allow-Credentials: true
Vary: Origin

Two precision points matter here.

First, CORS credentials are not the same as the Authorization header. A script can set an Authorization header on its own request if the browser allows that header through preflight and the application has the token. Credential mode mainly controls ambient browser managed credentials such as cookies and HTTP auth.

Second, cookies still follow cookie rules. If a session cookie is marked SameSite=Lax or SameSite=Strict, certain cross site requests will not carry it even if your JavaScript sets credentials: 'include'. Developers sometimes misread this as CORS “not working”, when the real blocker is the cookie policy. CORS and cookie policy compose, and both have to align before a credentialed cross origin read succeeds.

One dangerous consequence follows from all of this: a misconfigured credentialed CORS policy can expose authenticated data even though the API's own authentication is functioning exactly as designed. The attacker does not need to steal the cookie first. They just need the victim's browser to send it and the browser to be told that the hostile origin may read the answer.

Same Site Is Not The Same As Same Origin

This distinction causes a lot of real world confusion because CORS is origin based while cookies increasingly talk about site.

Roughly speaking, “same site” is about whether two URLs belong to the same registrable domain under the same scheme. https://app.example.eu and https://api.example.eu are different origins, but they are usually the same site. https://example.eu and https://example.co.uk are different sites. http://app.example.eu and https://app.example.eu are different sites as well because scheme now matters in the same site model.

Why does that matter? Because developers often build a frontend on one subdomain and an API on another, then assume that if cookies flow between them the requests must somehow be same origin. They are not. The browser may consider the navigation same site for cookie purposes and still require CORS for JavaScript reads because the origins differ.

That means you can easily end up in this state:

  • the SPA on https://app.example.eu calls https://api.example.eu
  • the browser sends the session cookie because the request is same site and the cookie policy permits it
  • the API authenticates the user successfully
  • the browser still blocks JavaScript from reading the response because the API sent no CORS headers

From the server team's point of view the session looks fine. From the frontend team's point of view the request looks “mysteriously blocked”. The missing piece is that cookie eligibility and CORS are different gates.

The reverse confusion happens too. A team sets Access-Control-Allow-Origin: https://app.example.eu and Access-Control-Allow-Credentials: true, then wonders why the cookie still does not arrive from a truly cross site page such as https://partner.example.net. The answer may be SameSite=Lax or SameSite=Strict, not CORS.

This is one reason CORS bugs are so often misdiagnosed as auth bugs and auth bugs are so often misdiagnosed as CORS bugs. The browser evaluates several independent policies:

  • origin based script access via the same origin policy and CORS
  • site based cookie sending rules via SameSite
  • cookie domain and path matching
  • the fetch credential mode requested by the script

All four can be relevant on the same request. The debugging habit that helps is to ask separate questions in order:

  1. Is this request cross origin?
  2. Is it same site or cross site for cookie purposes?
  3. Did the browser send credentials?
  4. Did the response include the CORS headers needed for script access?

Once you split the problem that way, the browser's behaviour becomes much less mysterious.

What JavaScript Actually Gets Back

A useful mental model is that CORS does not decide whether bytes arrive at the browser. It decides which parts of those bytes the browser is willing to surface through JavaScript APIs.

When CORS fails on a normal fetch(), the script does not get a “real” Response object with status, body, and headers that it can inspect partially. It gets a rejected promise that looks much like a network failure from script level. DevTools may show a 200 response on the wire. The JavaScript calling code still sees failure because the browser refused to expose the response.

That is why these two observations can coexist without contradiction:

  • Network panel: request completed, server returned JSON
  • Application code: TypeError: Failed to fetch

The browser is deliberately collapsing “response blocked by policy” into something that does not let the calling origin learn about the protected response.

There are a few special cases worth knowing.

For successful CORS responses, JavaScript may only read a small safelist of response headers unless the server adds Access-Control-Expose-Headers. Teams often miss this when they add trace IDs, rate limit headers, or pagination cursors and then wonder why browser code cannot read them.

For mode: 'no-cors', the browser returns an opaque response object. The request may succeed at the transport layer, but the script cannot read the body, most headers, or even the real status in a useful way. That mode exists to support legacy fetch patterns, not to make protected APIs readable.

For embedded resources such as images or stylesheets, the page may learn even less. An <img> tag can often cause a request to happen cross origin, but the script cannot freely inspect the response bytes unless other APIs explicitly allow it. That is why drawing a cross origin image onto a canvas without the right opt in taints the canvas.

This response visibility model is the thread that ties the whole system together. CORS is not merely “some headers around OPTIONS”. It is the browser deciding what the calling origin is allowed to observe.

The Headers That Matter, And The Ones People Confuse

The basic CORS header set is small. The confusion comes from what each header does, and when it matters.

Access-Control-Allow-Origin : The central decision point. Names the origin allowed to read the response, or * for public non credentialed use cases.

Access-Control-Allow-Credentials : Says the browser may expose the response to the calling script when the request was made with credentials. Does not work with *.

Access-Control-Allow-Methods : Used mainly in preflight responses. Lists which methods are allowed for the requested route and origin.

Access-Control-Allow-Headers : Used in preflight responses. Lists which non safelisted request headers the browser may send.

Access-Control-Expose-Headers : Controls which response headers JavaScript may read beyond the small built in safelist. This is why response.headers.get('X-Trace-Id') may return null even when DevTools clearly shows the header on the wire.

Access-Control-Max-Age : Tells the browser how long to cache a positive preflight result.

Vary: Origin : Not technically a CORS header, but operationally one of the most important. It tells shared caches that the response differs by request origin.

The last one is widely missed because the browser console error does not shout about caches. Imagine a CDN in front of your API. The first request comes from https://app.example.eu, the server correctly echoes that origin, and the CDN caches the response. The second request comes from https://admin.example.eu. Without Vary: Origin, the CDN may serve the cached response that still says Access-Control-Allow-Origin: https://app.example.eu.

Best case, the second app breaks mysteriously because the browser sees a mismatched allow origin. Worse cases appear when a response body should not have been shared between those callers, or when cache key design around authenticated content is already sloppy. Vary: Origin is not an optional nicety. It is part of making origin reflection safe in the presence of shared caches.

There is another subtle confusion worth clearing up. Access-Control-Allow-Origin is about what the browser may expose to JavaScript. It is not a promise that the server authenticates only those origins. The server is still responsible for deciding whether the caller is authorised. CORS headers describe read permission to the browser. They do not replace application auth.

Misconfigurations That Turn CORS Into A Data Leak

Most real CORS vulnerabilities fall into a handful of patterns.

Reflecting Any Origin Header

The server receives Origin: https://evil.example and simply sends it back.

const origin = req.headers.get('origin') ?? ''
return new Response(body, {
  headers: {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Credentials': 'true',
  },
})

That is not an allowlist. It is an echo service. If cookies are sent, any hostile site can now read authenticated responses.

Loose Pattern Matching

Teams often try to allow “all our subdomains” with substring checks.

if (origin.includes('example.eu')) { ... }

That accepts https://example.eu.attacker.tld. Slightly less naive regexes fail too when they forget anchors, normalisation, or scheme checks. Exact string matching against a canonical allowlist is dull and correct.

Trusting null

Browsers can send Origin: null from sandboxed iframes, some local file contexts, and certain opaque origins. Treating null as benign because it “isn't a real site” is dangerous. If you allow it with credentials, you may be granting read access to contexts you did not intend to trust.

Sending CORS Headers On Every Route By Default

A static asset route returning public SVGs can reasonably send Access-Control-Allow-Origin: *. An account route returning invoices cannot. Blanket middleware that stamps one permissive policy onto every response often widens sensitive endpoints accidentally.

Forgetting That Public Read APIs And Authenticated APIs Need Different Policies

Many teams have both. A product catalogue endpoint meant for public use might be safely readable from any origin without credentials. A session backed account endpoint should not. Using the same wildcard policy for both because it is simpler is how the boundary disappears.

Misconfiguration does not always mean instant exploitation. If the endpoint returns public data, the real impact may be low. If no credentials or tokens are in play, the attacker may only learn data they could already retrieve directly. Severity depends on the data class, auth model, and whether the browser can attach useful credentials. The mechanism, however, is the same: the browser was told to expose a response to an origin that should not have had it.

CORS Does Not Replace CSRF Defence, Authentication, Or Server Side Policy

A surprisingly large number of systems rely on CORS to do jobs it was never designed to do.

CORS Is Not CSRF Protection

A hostile page can often still send a cross site request even when it cannot read the response. Classic HTML forms could always do that. Simple cross origin requests still can. If your state changing endpoint depends only on the browser carrying session cookies, then a CORS failure does not save you from unwanted actions. The action may happen anyway.

That is why CSRF defences such as SameSite cookies, synchroniser tokens, double submit tokens, and origin checks on sensitive form posts still matter. CORS controls read access for script. CSRF is about unwanted writes using the victim's ambient authority.

CORS Is Not Authentication

If your API says “I trust any request whose Origin header is https://app.example.eu”, you do not have authentication. You have a browser convention that non browser clients can spoof trivially. A curl command can send any Origin string it likes.

Real authentication comes from session cookies, bearer tokens, mutual TLS, signed requests, or some other server verifiable mechanism. CORS may allow the browser to present or read those authenticated exchanges. It is not the credential itself.

CORS Does Not Apply To Server To Server Traffic

Your Next.js server action calling a backend API from Node does not hit browser CORS enforcement. Your cron job does not. Your data warehouse export worker does not. If those calls fail, the reason is not CORS unless you built your own CORS like logic on the server side.

CORS Does Not Guarantee Resource Isolation By Itself

Even without readable XHR or fetch() responses, some resources can still be embedded cross origin. Images, scripts, iframes, fonts, and stylesheets each have their own browser rules. If the resource itself is sensitive, you may need other headers or application controls beyond CORS, such as Cross-Origin-Resource-Policy, Content-Security-Policy, X-Frame-Options or frame-ancestors, signed URLs, or plain authentication checks.

Once you separate these responsibilities, CORS gets much easier to reason about. It is one layer in the browser boundary, not the whole boundary.

A Credentialed Misconfiguration, Step By Step

It is easier to see the real risk if we walk one exploit all the way through.

Imagine a SaaS admin API at https://api.example.eu and the legitimate frontend at https://app.example.eu. The user logs in on the real app, receives a session cookie, and later visits a hostile marketing site at https://evil.example. The API has this bug:

const origin = req.headers.get('origin') ?? ''
 
return Response.json(accountData, {
  headers: {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Credentials': 'true',
  },
})

Now the hostile page runs:

fetch('https://api.example.eu/account', {
  credentials: 'include',
})
  .then(r => r.json())
  .then(data => sendToAttacker(data))

Here is what happens.

  1. The hostile page is cross origin relative to the API.
  2. The browser sees credentials: 'include' and checks whether the session cookie may be sent under the cookie's site policy.
  3. If the cookie is eligible, the browser sends the request with the victim's authenticated session.
  4. The API returns the user's private account data and reflects Origin: https://evil.example.
  5. Because the response says that exact origin is allowed and credentials are permitted, the browser exposes the JSON to the hostile script.
  6. The hostile script exfiltrates it.

No authentication was bypassed. No server side ACL was broken. The API did exactly what it normally does for an authenticated user. The browser was simply instructed to hand the answer to the wrong origin.

Sometimes developers assume a preflight would save them here. Often it does not even happen. A credentialed GET with no non safelisted request headers can be a simple request. The browser may send it directly, then enforce the read decision on the response. That is one reason “but we did not allow OPTIONS” is not a CORS defence.

Even when a preflight does happen, reflecting the origin there is still enough to lose. The browser's policy engine is only as strict as the headers you send it. If every origin gets a yes, the browser becomes an authenticated data delivery mechanism for every origin.

This exploit shape is also why bug severity depends on the data behind the route. If the route returns a public product list, the impact is usually minor. If it returns invoices, access tokens, account metadata, or internal admin responses, the impact can be serious very quickly.

The clean defensive habit is to think in route classes.

  • public unauthenticated resources may use * if they are truly public
  • browser facing authenticated APIs should allow only exact trusted origins
  • sensitive routes that never need browser cross origin access should send no CORS headers at all

Once you classify routes that way, reflected origin code stops looking convenient and starts looking obviously dangerous.

The Browser's Preflight Cache And Your CDN Cache Are Different Systems

One reason CORS problems linger is that two different caches are often involved, and teams talk about them as though they were one thing.

The preflight cache lives in the browser. It stores permission decisions such as “https://app.example.eu may send POST with authorization, content-type to this route for the next 600 seconds”. Access-Control-Max-Age influences this cache. Its purpose is to avoid repeating the permission question on every non simple request.

Your HTTP cache or CDN cache is different. It stores actual responses, such as JSON bodies, 204 preflight replies, and static assets, according to HTTP cache semantics and your edge configuration. Vary: Origin influences this layer because it tells shared caches that the response differs by origin.

Those layers solve different problems.

  • Access-Control-Max-Age reduces browser side preflight round trips.
  • Vary: Origin prevents cache confusion across callers.
  • ordinary cache controls such as Cache-Control: private or no-store decide whether the response body itself should be cached at all.

It is perfectly possible to configure one correctly and the other disastrously.

For example, a team may tune Access-Control-Max-Age: 86400 and celebrate fewer OPTIONS calls while still forgetting Vary: Origin, which means the CDN can replay the wrong allow origin to a different frontend. Another team may add Vary: Origin everywhere but omit Access-Control-Max-Age, so every browser write call still pays an extra round trip. A third team may get both CORS layers right and still accidentally cache authenticated JSON at the edge because the route lacks Cache-Control: private.

Preflight responses themselves can also be cached by intermediaries if you choose to let that happen, which adds another source of surprise. The main operational rule is simple: know which cache you are tuning. If you are changing Access-Control-Max-Age, you are shaping browser behaviour. If you are changing Vary or edge cache rules, you are shaping shared response reuse. Those are not interchangeable levers.

Once you separate them, a lot of “random” behaviour becomes easier to explain. The first request after a deploy preflights because the browser cache is cold. The second request skips preflight because the browser permission cache is warm. A different frontend then breaks because the CDN served a cached response with the wrong Access-Control-Allow-Origin. Three observations, two caches, one fixable mental model.

A Production CORS Policy That Survives Real Traffic

The boring production pattern is usually the right one.

  1. Maintain an exact allowlist of trusted frontend origins.
  2. Apply CORS only to routes that are meant to be called from browsers on other origins.
  3. Return no CORS headers at all for origins you do not trust.
  4. For credentialed routes, echo the exact trusted origin and send Access-Control-Allow-Credentials: true.
  5. Add Vary: Origin whenever the header value can differ by origin.
  6. Handle OPTIONS explicitly for non simple routes.
  7. Log rejected origins and unexpected preflight shapes.

A simple implementation looks like this.

const allowedOrigins = new Set([
  'https://app.example.eu',
  'https://admin.example.eu',
  'http://localhost:3000',
])
 
function buildCorsHeaders(req: Request) {
  const origin = req.headers.get('origin') ?? ''
  if (!allowedOrigins.has(origin)) return null
 
  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Credentials': 'true',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Trace-Id',
    'Access-Control-Expose-Headers': 'X-Trace-Id',
    'Access-Control-Max-Age': '600',
    'Vary': 'Origin',
  }
}

Then use that function per route class, not as a global sticker applied to everything. Public endpoints may instead use a deliberately separate helper:

const publicCorsHeaders = {
  'Access-Control-Allow-Origin': '*',
}

That separation matters. It makes it much harder for a future maintainer to accidentally turn a session backed route into a wildcard readable route.

There are a few operational extras worth considering.

If you front the API with a CDN, make sure the cache key strategy for authenticated routes is already sound. Vary: Origin does not repair a cache that should never have stored a user specific response in the first place.

If you rely on preflights heavily, monitor their rate and latency. A missing Access-Control-Max-Age or an overly fragmented set of custom headers can double perceived latency for browser clients.

If you ship multiple frontends, keep the allowlist in one reviewed place. Many CORS incidents happen during staging to production drift: a temporary preview origin was added, nobody removed it, and months later it became the easiest trusted foothold.

Finally, keep dev convenience separate from production policy. http://localhost:3000 belongs in local development because developers need to work. It does not belong in production unless you genuinely expect production browsers to call your API from a developer laptop on localhost.

One more pragmatic rule helps during releases: test CORS with at least two real origins, not one. A policy that works for https://app.example.eu can still fail for https://admin.example.eu because of a missing Vary: Origin, an incomplete allowlist, or a route that only one frontend exercises. Browser automation, a small integration harness, or even two manual tabs on different origins will catch more than staring at middleware code. CORS bugs are often route specific and cache sensitive, so the fastest proof is a real browser making the real requests your users will make.

What The Lab And Quiz Focus On

The lab paired with this article lets you switch between several common request shapes and several server policy outcomes, then step through the browser's decision process. In beginner mode it answers the practical question developers ask at 18:20 when the console is red: did the request go out, did a preflight happen, and can the script read the response? In advanced mode it shows the exact headers, the credential rules, the preflight cache, and the cache level reason Vary: Origin matters.

The quiz then checks the parts people most often blur together: origin matching, the difference between simple and non simple requests, why wildcard plus credentials is blocked, why CORS is not CSRF protection, and why reflecting arbitrary origins is worse than a missing header.

If you keep one sentence from this article, make it this one: CORS is the browser's read permission system for cross origin HTTP. Once you stop asking it to be a firewall, an authenticator, and a CSRF defence all at once, its behaviour becomes much more predictable, and your configuration becomes much less likely to leak data by accident.