CORS Explained (2026): Same-Origin, Preflight, Credentials, Pitfalls

Nov 10, 2026 · 11 min · Ajay Kumar

I have watched more engineering hours vanish into CORS errors than into almost any other browser behaviour, and nearly all of them came from the same misunderstanding: people treat CORS as a firewall on the server, when it is a set of rules the browser follows before it hands a response to your JavaScript. Once that clicks, CORS stops being mysterious. This post explains CORS as it stands in 2026: the same-origin policy it relaxes, what makes a request simple or preflighted, how credentials change every rule, why CORS is not a security control for your API, the misconfigurations that turn into real vulnerabilities, and how Chrome replaced Private Network Access with a permission prompt. If you are staring at a red console message right now, the paired guide, how to fix "No Access-Control-Allow-Origin header" errors, is the hands-on version.

What is the same-origin policy, and what does CORS relax?

An origin is the triple of scheme, host and port. https://app.example.com and https://api.example.com are different origins, and so are http://example.com and https://example.com, and example.com and example.com:10777. AWS's API Gateway CORS documentation lists exactly those four cases (domain, subdomain, port, protocol) as what makes a request cross-origin, which is a useful reminder that "same company" is not "same origin".

The same-origin policy says a script loaded from one origin can send requests to another origin but cannot read the responses. That asymmetry is the key to everything. The browser happily lets a page submit a form or load an image cross-origin, because the web has worked that way since the 1990s. What it refuses to do, by default, is let fetch() hand the response body to code from a different origin.

CORS (Cross-Origin Resource Sharing, defined in the WHATWG Fetch Standard) is the opt-in mechanism a server uses to say "this origin may read my responses". The server says it with response headers. The browser enforces it. Your server never blocks anything because of CORS; it only describes what it is willing to share.

Simple requests vs preflighted requests

Browsers split cross-origin requests into two kinds. A simple request goes straight to the server; a non-simple one gets a preflight first.

Per MDN's CORS guide, a request is simple only if all of these hold: the method is GET, HEAD or POST; the only manually set headers are the CORS-safelisted ones (Accept, Accept-Language, Content-Language, Content-Type, and Range with a single range); Content-Type is one of application/x-www-form-urlencoded, multipart/form-data or text/plain; no upload listeners are registered on an XMLHttpRequest; and no ReadableStream is used in the body.

Notice what that excludes. Content-Type: application/json makes a request non-simple. So does an Authorization header, so does PUT, PATCH or DELETE, and so does any X-Something header your frontend adds. In practice, almost every modern API call from a single-page app is preflighted.

The preflight is an OPTIONS request the browser sends on its own. The Fetch Standard's preflight algorithm adds Access-Control-Request-Method with the real method and Access-Control-Request-Headers with the non-safelisted header names, and the preflight does not carry the page's cookies or Authorization header. The server must answer with a status in the 200 to 299 range (the spec calls it an "ok status"), the right Access-Control-Allow-* headers, and no redirect. Chromium's error text for the last two cases is literally "It does not have HTTP ok status" and "Redirect is not allowed for a preflight request", straight from its CORS error strings.

Only if the preflight passes does the browser send the real request. Then it runs the CORS check a second time on the real response, which needs its own Access-Control-Allow-Origin. Forgetting that second set of headers on the actual GET or POST (because only the OPTIONS handler was configured) is one of the most common bugs I see.

One cross-origin fetch(), as the browser sees it 1. fetch() script on origin A calls origin B browser adds the Origin header 2. Simple? GET, HEAD or POST safelisted headers form or text body yes: go to step 4 3. Preflight OPTIONS, no cookies Request-Method and Request-Headers needs 2xx, no redirect 4. Real request sent with cookies if credentials: include server runs it and returns a response 5. CORS check Allow-Origin match? credentials rules ok? fail: JS sees only a TypeError simple requests skip the preflight Preflight cache (Access-Control-Max-Age) default 5 s if absent; Chromium caps at 7,200 s (since v76); Firefox caps at 86,400 s The server always receives and runs the real request. CORS only decides whether the page's JavaScript may read the response. It is not access control.
The CORS decision flow from the Fetch Standard and MDN's CORS guide; Max-Age caps from MDN's Access-Control-Max-Age reference.

The response headers, and what each one actually controls

There are six response headers that matter. The table is the short version; the rules under it are where people get hurt.

Header Sent on What it does Common mistake Source
Access-Control-Allow-Origin preflight and real response One origin, or *, that may read the response Listing several origins in one header (only one value is allowed) MDN, Chromium
Access-Control-Allow-Credentials both true lets cookies and HTTP auth be used and read Pairing it with Allow-Origin: * MDN
Access-Control-Allow-Methods preflight Methods allowed beyond the safelisted ones Omitting PUT, PATCH or DELETE Fetch
Access-Control-Allow-Headers preflight Request headers allowed beyond the safelist Assuming * covers Authorization (it never does) MDN
Access-Control-Expose-Headers real response Response headers JS may read beyond the safelist Wondering why ETag or X-Request-Id reads as null MDN
Access-Control-Max-Age preflight Seconds the preflight result may be cached Setting 86400 and expecting Chrome to honour it MDN

Three rules are worth memorising. First, Access-Control-Allow-Origin carries exactly one origin or *. If you need several, the server checks the incoming Origin against an allowlist and echoes back the one that matched. Second, when it does that, it must send Vary: Origin, which MDN explicitly recommends, or a CDN or browser cache will serve one origin's CORS headers to another. Third, the preflight cache is short whatever you ask for: MDN documents a 5 second default when the header is absent, a 2 hour cap in Chromium since version 76, and 24 hours in Firefox.

How credentials change the rules

A request is credentialed when the page opts in, with fetch(url, { credentials: "include" }) or xhr.withCredentials = true, and the browser attaches cookies or HTTP authentication. For credentialed requests, MDN's guide says the server must not use the * wildcard in Access-Control-Allow-Origin, and the same ban applies to Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers. It must also send Access-Control-Allow-Credentials: true, or the browser discards the response.

This is a deliberate design choice. Wildcards are fine for genuinely public data, such as a font, a public JSON feed or an unauthenticated API. The moment cookies are involved, the browser forces the server to name the origin it trusts, so that "anyone can read this" and "anyone can read this with your session" never happen by accident.

There is one more wrinkle that trips people in 2026: third-party cookie policy. MDN notes that cookies in CORS requests follow normal third-party cookie rules. If your frontend and API sit on different sites (not just different subdomains), a browser that blocks third-party cookies may not send the session cookie at all, whatever your CORS headers say. Same-site subdomains with a SameSite=Lax cookie, or a token in the Authorization header, avoid that class of problem. The trade-offs between cookies and bearer tokens are covered in JWT vs sessions.

Is CORS a security feature? Not for your server

CORS protects users from having their data read by a malicious page in their own browser. It does nothing to stop anyone else from calling your API. curl, Postman, a Python script and a server-side proxy all ignore CORS completely, because CORS is enforced by browsers.

Even inside the browser, CORS does not stop requests from arriving. PortSwigger's Web Security Academy puts it bluntly: CORS "is not a protection against cross-origin attacks such as cross-site request forgery (CSRF)". A simple POST with a form content type is sent and executed before the browser ever looks at the CORS headers. The browser only hides the response.

I checked this locally with Express 5.3.0 and the cors package 2.8.6, using an allowlist that does not include the attacker's origin:

# Express 5.3.0 + cors 2.8.6, allowlist = https://app.example.com, http://localhost:5173
# Request from an origin that is NOT on the allowlist:
curl -si http://localhost:3999/api/me -H 'Origin: https://evil.example'

# Real output (trimmed):
# HTTP/1.1 200 OK
# Content-Type: application/json; charset=utf-8
# {"ok":true}
#
# No Access-Control-Allow-Origin header, so a browser hides the body from the page.
# But the handler ran and returned 200. Any side effect already happened.

So authentication, authorization, CSRF tokens or SameSite cookies, and rate limits still have to live on the server. CORS is a sharing policy, and it belongs in the same mental box as a Content-Security-Policy header: instructions to a browser about what it may do on behalf of a user. If you are thinking about where browser-enforced controls sit relative to server controls, the OWASP Top 10 for LLM apps post makes the same point about guardrails that run in the wrong place.

CORS misconfigurations that become vulnerabilities

CORS cannot make your server more secure, but it can make your users less secure. The dangerous configurations all share one shape: the server tells the browser that an untrusted origin may read credentialed responses. PortSwigger's research lists the classic forms, and the 2018 USENIX Security paper by Chen et al., We Still Don't Have Secure Cross-Domain Requests, measured how common they were: across the Alexa top 50,000 sites and 97,199,966 subdomains, 132,476 CORS-configured subdomains (27.5%) across 2,913 base domains (13.2%) had insecure configurations. The data is old, but none of the root causes have gone away.

Misconfiguration What it looks like Why it is exploitable
Reflected origin with credentials Server echoes any Origin into Allow-Origin and sends Allow-Credentials: true Any website can read the victim's authenticated responses
Sloppy allowlist matching endsWith("example.com"), startsWith, or an unanchored regex attackerexample.com or example.com.attacker.net passes
Trusting null Allow-Origin: null for local development Sandboxed iframes produce a null origin on demand
Trusting every subdomain *.example.com including legacy or HTTP hosts One XSS or one HTTP subdomain becomes a read primitive on the API

The reflected-origin case is rarely written on purpose. It usually comes from a framework default. The cors package for Express documents that origin: true reflects the request origin, which is safe for a public API and dangerous with credentials: true. FastAPI's documentation says you cannot use ["*"] for origins together with allow_credentials=True, but Starlette's middleware does not refuse that combination. I ran it with FastAPI 0.162.2 on Starlette 1.10.0, with allow_origins=["*"] and allow_credentials=True, and a request from https://evil.example carrying a cookie came back with access-control-allow-origin: https://evil.example and access-control-allow-credentials: true. That matches the comment in Starlette's CORS middleware source, which switches from * to the specific origin when credentials are allowed. In other words, the "allow everything" setting quietly becomes "trust everyone with cookies".

The fix in every case is the same: an exact-match allowlist of full origins (scheme, host and port), anchored regexes only if you truly need preview environments, never null, and credentials only for origins you control. A scanner such as the ones in SAST vs DAST vs SCA will usually flag reflected origins, but a one-line curl in CI with a fake Origin header catches it faster.

Private Network Access is gone: Chrome's Local Network Access prompt

For several years Chrome worked on Private Network Access (PNA), which would have required an extra preflight header, Access-Control-Request-Private-Network, before a public website could reach a device on your LAN or localhost. That effort never fully shipped. Chrome's Local Network Access announcement says LNA "replaces that effort", gating those requests behind a user permission prompt instead of a server opt-in, with the launch in Chrome 142.

Chrome's developer guidance now describes two permissions, local-network (public to private addresses such as RFC 1918 ranges) and loopback-network (public or local origins to 127.0.0.0/8, ::1 or localhost); the intent to ship for that split targeted Chrome 145, with the old local-network-access name kept as an alias. A few details matter for engineers:

  • The permissions are denied automatically in insecure (http://) top-level contexts, so an HTTP intranet page calling a local device will fail.
  • Cross-origin iframes are blocked by default; the embedding page must delegate with allow="local-network; loopback-network".
  • fetch() accepts targetAddressSpace: "local" or "loopback" so an HTTPS page can call an http:// device without being blocked as mixed content, and the browser verifies the resolved IP really is in that space.

You will still see allow_private_network in Starlette and the PNA headers in older middleware. They are harmless, but they no longer do anything useful in Chrome, and CORS headers on your local service do not replace the user's permission.

FAQ

What does CORS actually protect against?

CORS protects a user's data from being read by a malicious website running in that user's browser. Without the same-origin policy, any page you visit could call your bank's API with your cookies and read the response. CORS lets servers relax that rule for origins they trust. It does not protect the server from direct requests.

Why does my request send an OPTIONS request first?

The browser sends a preflight OPTIONS request whenever the request is not simple: a method other than GET, HEAD or POST, a Content-Type of application/json, or any custom header such as Authorization. The server must answer the preflight with a 2xx status and matching Access-Control-Allow-* headers. You can reduce repeat preflights with Access-Control-Max-Age, up to 7,200 seconds in Chromium.

Can I use Access-Control-Allow-Origin * with cookies?

No. For credentialed requests the browser rejects a wildcard origin, and also treats * as a literal value in the allow-headers, allow-methods and expose-headers lists. Echo the specific allowed origin, send Access-Control-Allow-Credentials: true, and add Vary: Origin.

Does CORS stop CSRF attacks?

No. A simple cross-origin request, such as a form POST, reaches your server and executes before the browser checks CORS headers; the browser only hides the response. Use CSRF tokens, SameSite cookies, or require a non-simple request (for example a custom header or JSON body) so that a preflight must succeed first.

This quarter I would do three things on any API that a browser calls: grep the codebase and gateway configs for reflected origins and credentials combined with wildcards, replace them with an explicit allowlist that lives in config next to the deployment rather than in application code, and add a CI check that sends a fake Origin and fails the build if it comes back in Access-Control-Allow-Origin. That is ordinary platform discipline, and it is cheaper than explaining an account-takeover bug. If you are currently fighting a specific error, the step-by-step CORS error fix guide has the configs for Express, FastAPI, nginx, API Gateway, CloudFront, S3 and dev proxies.

Related: Nginx vs Caddy vs Traefik in 2026, Vibe-Coded App Security in 2026 and Enterprise-Ready SaaS in 2026.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related