You're viewing the readable version of this site. The interactive extras (search, diagrams, read-aloud) need JavaScript and a current browser. Enable JavaScript; if it is already enabled, update your browser.

Notes · Dissecting Real Systems

growing

The Header That Grants Nothing

CORS does not protect your server. It relaxes a rule about who may read a reply — and almost every misconfiguration comes from believing it does the opposite.

· · 17 min read

security, web, cors, http, browsers, caching, dissecting-systems

The Origin header is a version of the Referer [sic] header that does not reveal a path.

Fetch Standard §3.2, including the spelling error the web has been carrying since 1996

Cite this
APA
Mangalapilly, Y. J. (2026, August). The Header That Grants Nothing. Saṃhitā Notes. https://yesudeep.com/blog/the-header-that-grants-nothing/
BibTeX
@online{mangalapilly2026the,
  author  = {Yesudeep Jose Mangalapilly},
  title   = {The Header That Grants Nothing},
  journal = {Sa\d{m}hit\=a Notes},
  year    = {2026},
  month   = {August},
  url     = {https://yesudeep.com/blog/the-header-that-grants-nothing/},
  urldate = {2026-08-12},
}
Plain
Yesudeep Jose Mangalapilly. “The Header That Grants Nothing.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-header-that-grants-nothing/.
RIS
TY  - ELEC
AU  - Mangalapilly, Yesudeep Jose
TI  - The Header That Grants Nothing
T2  - Saṃhitā Notes
PY  - 2026
UR  - https://yesudeep.com/blog/the-header-that-grants-nothing/
Y2  - 2026-08-12
ER  - 

What CORS actually governs, and what it leaves wide open. The CORS article in the web security series examines the mechanism everyone configures and few read. By the end you'll know why the request is sent regardless of your headers, why * and credentials are incompatible as a consequence rather than a rule, which header the wildcard silently excludes, why the preflight cache is the one cache in the platform you cannot invalidate, and where CORS came from — which is not where you would guess.

Start with what the browser refuses to do.

The opening piece in this series put an asymmetry at the center of web security: a page may send a request to another origin, and may not read the reply. Sending is permitted, reading is refused. Nearly every attack in the field is downstream of which side of that line it needs.

CORS is the machinery that governs the read side. It exists because "reading is refused" is too strict for a web with APIs on separate hosts — so there has to be a way for a server to say this origin may read me. That is all it is.

CORS — Cross-Origin Resource Sharing. Note the last word. It is a sharing protocol: the server opts in to sharing a response with an origin that the same-origin policy would otherwise refuse. MDN: CORS.

The direction everyone gets backwards

Here is the shape of the misunderstanding, and it matters because it is load-bearing for whether you deploy anything correctly.

What CORS governs, on a timeline. The request is sent and the server acts on it; the check happens afterwards, in the browser, and decides only whether the calling script may read the reply.

An attacker's page issues a fetch to your API. The browser sends it — with cookies, if the request is credentialed and your cookie policy permits. Your server authenticates the session and performs whatever the endpoint does. The response comes back. Then the browser looks at Access-Control-Allow-Origin, finds it absent, and refuses to hand the response to the calling script.

The money has moved. The row is deleted. The browser has protected the attacker's ability to read the receipt, and nothing else.

For a request that needs no preflight, the side effect has already happened by the time CORS has an opinion. That gap is not a bug in CORS — it is the reason CSRF is a separate problem with separate defenses.

The Fetch Standard states the consequence with unusual bluntness, in a note about when it is safe to send permissive CORS headers at all:

Thus in other words, if a resource cannot be accessed from a random device connected to the web using curl and wget the aforementioned header is not to be included. If it can be accessed however, it is perfectly fine to do so.

Read that as a test. If curl can reach it without credentials, CORS headers cost you nothing. If curl cannot — because the endpoint depends on a cookie — then CORS headers are not what is protecting it, and adding them can only take protection away.

Important

A server sending no CORS headers is in the most restrictive state, not an unprotected one. Every header you add shares something. There is no configuration of CORS that makes an endpoint harder to reach than sending nothing at all.

The eight steps, and what falls out of them

The CORS check is eight steps long and worth reading in full, because three famous behaviors are consequences of it rather than rules bolted onto it.

  1. Let origin be the result of getting Access-Control-Allow-Origin from response's header list.
  2. If origin is null, then return failure.
  3. If request's credentials mode is not "include" and origin is *, then return success.
  4. If the result of byte-serializing a request origin with request is not origin, then return failure.
  5. If request's credentials mode is not "include", then return success.
  6. Let credentials be the result of getting Access-Control-Allow-Credentials from response's header list.
  7. If credentials is true, then return success.
  8. Return failure.

Why the wildcard and credentials are incompatible

Everyone knows Access-Control-Allow-Origin: * stops working when you send cookies. Almost nobody can say why, and the usual explanation — "it's a special rule, for safety" — is wrong.

Look at step 3. It is the only step that accepts *, and it is gated on credentials mode not being include. Send credentials and step 3 is skipped. Control falls to step 4, which is a byte comparison against the request's serialized origin. The string * is not a serialized origin, so it cannot match, and the check fails.

* plus credentials is not forbidden. It is unreachable — the only step that would have accepted the wildcard was skipped, and what remains is an equality test the wildcard cannot pass.

That distinction is worth the paragraph because it predicts the fix. You do not need an exception; you need an exact origin, because step 4 is byte equality and nothing else will satisfy it.

"Null is not null"

Step 2 carries a note in the spec that reads like a joke and is a security control:

Note: Null is not null.

The absence of the header is spec-null and fails at step 2. The string null is a legitimate serialized origin — it is what an opaque origin serializes to — and it will happily byte-match at step 4.

Where does a request get an opaque origin? A sandboxed iframe, for one, which is something any page can create about itself. MDN spells out the consequence:

It may seem safe to return Access-Control-Allow-Origin: "null"; however, the origin of resources that use a non-hierarchical scheme (such as data: or file:) and sandboxed documents is serialized as null. Many browsers will grant such documents access to a response with an Access-Control-Allow-Origin: null header, and any origin can create a hostile document with a null origin.

Any origin can create a hostile document with a null origin. Allowlisting null is therefore equivalent to allowlisting everyone, in a form that looks restrictive.

The wildcard that is not a wildcard

* appears in four CORS response headers, and in every one of them its quantifier-hood is conditional. From the spec:

For Access-Control-Expose-Headers, Access-Control-Allow-Methods, and Access-Control-Allow-Headers response headers, the value * counts as a wildcard for requests without credentials. For such requests there is no way to solely match a header name that is *.

So once credentials are in play, * degrades to a literal header name. Not an error, not a warning — a name that will never match anything real.

And there is a carve-out that survives even the credential-free case:

A CORS non-wildcard request-header name is a header name that is a byte-case-insensitive match for Authorization.

Warning

Access-Control-Allow-Headers: * does not authorize the Authorization header. It must be named explicitly. The omission is a concrete production bug with a one-word fix, and the failure looks like an inexplicable preflight rejection on exactly the requests that carry a bearer token.

One more asymmetry worth carrying: on the response side, only seven headers are readable by default — Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Notice what is missing. ETag is not on that list, which surprises everyone who builds a conditional-request or pagination API and finds their client cannot see the header the whole design depends on. Expose it deliberately or it is not there.

The third cache

The second piece in this series dissected two caches — the shared one in front of your origin, and the private one in the browser — and the mistakes that live between them. There is a third, it is created by CORS, and it has a property neither of the others has.

Three caches, and one of them has no invalidation story at all. The preflight cache is keyed on a tuple you cannot address and cleared by a mechanism you cannot invoke.

Here is the entry, verbatim:

A cache entry consists of: key (a network partition key), byte-serialized origin (a byte sequence), URL (a URL), max-age (a number of seconds), credentials (a boolean), method (null, *, or a method), header name (null, *, or a header name).

And the lifetime rule:

Cache entries must be removed after the seconds specified in their max-age field have passed since storing the entry. Cache entries may be removed before that moment arrives.

Two things follow, and the second is the interesting one.

First, Access-Control-Max-Age is a ceiling, not a promise. The browser may forget sooner. It may also clamp the number you asked for — the spec provides for "an imposed limit on max-age" without saying what that limit is, which is left to implementations.

Second — and this is the part with no equivalent anywhere else in HTTP caching — there is no way to invalidate an entry. The spec defines a "clear cache entries" operation, but nothing in Fetch calls it; it is invoked from outside, when a network error occurs on the actual request. No response header clears a preflight. No JavaScript API clears it. Your server cannot retract a decision it made five seconds ago.

Every other cache on the web has a purge story. The preflight cache has Access-Control-Max-Age, set once at write time, and after that you wait.

The practical consequence: if you tighten a CORS policy and clients keep using the old one, that is not a bug, it is the design. Your options are to wait out the max-age, or to change the URL — entries are keyed on the request's current URL, so a different path or query string is a cache miss rather than a stale hit. The entry also lives on the user agent, not the document, so it survives navigation; reloading the page does not help.

Note

Set Access-Control-Max-Age deliberately, in both directions. The default is five seconds, which means a chatty client can double its request count on preflights alone. A long value buys latency and costs you the ability to change your mind at any speed. The cache lifetime is the only knob you get.

Why the safelist looks so arbitrary

Some cross-origin requests trigger a preflight and some do not, and the dividing line looks like a committee's compromise. It is better than that: it is an archaeological record.

A request avoids the preflight when its method is GET, HEAD, or POST, it sets no unusual headers, and its Content-Type is one of exactly three values — application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Those three are precisely what an HTML <form> could already produce, without JavaScript, before CORS existed. The safelist is not a judgment about which requests are safe. It is a list of what the web could already do, grandfathered in because forbidding it would have broken every existing page.

Imagine adding a lock to a building that has been open for twenty years. You cannot lock the doors people already walk through every day — so you lock the new ones and leave the old ones as they were. The old doors are not safer. They are just older, and shutting them would break too much.

That is the entire reason CSRF is a distinct problem. An attacker who confines themselves to what a form could do gets no preflight, so there is no round trip in which your server could have objected. The request arrives, and the only protections that matter are the ones the fetch metadata piece describes: looking at who sent it, rather than asking permission to answer.

What makes any of this trustworthy

There is a reason a server can believe the Origin header at all, and it is the same mechanism the fetch-metadata piece rests on. Origin is a forbidden request header — page script cannot set it. So can Cookie, Host, Referer, and anything beginning with Sec-.

If name when byte-lowercased starts with proxy- or sec-, then return true.

That prefix rule is why the Sec-Fetch-* family exists with that spelling: the name itself buys unforgeability. And it means an allowlist check against Origin is checking something the calling page could not have chosen — which is exactly what makes the eight-step algorithm worth running.

How the allowlist actually fails

The check is sound. The allowlists people write against it are where the bugs are, and James Kettle cataloged them in Exploiting CORS Misconfigurations for Bitcoins and Bounties (2016). Four patterns, all still live:

  • Origin reflection — echoing the request's Origin back as the Access-Control-Allow-Origin. It passes step 4 by construction, for everyone. This is an allowlist of the entire web written in one line.
  • Trusting null — origin serialization explains why any sandboxed iframe can reach it.
  • Prefix, suffix, and regex matching — a site restricting to advisor.com subdomains trusted definitelynotadvisor.com; a site trusting btc.net accepted https://btc.net.evil.net. The check is string matching pretending to be structural parsing.
  • Protocol bypass — allowlisting an origin without pinning the scheme, so http:// is accepted alongside https://. The scheme omission is where the HTTPS commitment from the HSTS piece silently leaks: everything is served over TLS, and the CORS allowlist quietly readmits the plaintext origin a network attacker can occupy.

These are not rare. We Still Don't Have Secure Cross-Domain Requests (Chen et al., USENIX Security 2018) surveyed the state of CORS across the web and found over a fourth of sites carrying some CORS issue — a number worth holding next to the fact that the mechanism grants read access to credentialed responses.

The consequence is not confined to the site that wrote the bad allowlist, which is the part worth internalizing. Careful Who You Trust (Meiser, Laperdrix and Stock, ASIA CCS 2021) crawled the Tranco top 5,000 and built a graph of who trusts whom through domain relaxation, postMessage, and CORS. Scanning that set, the authors found cross-site scripting flaws on 1,376 origins, and 333 of those origins are trusted by at least one other origin with the right to execute, to change persistent storage, or to read.

Read that as a sentence about your own site: an allowlist entry is a statement that your data is as safe as the worst bug on somebody else's server. You do not review their code, you are not told when it changes, and their XSS becomes your disclosure by way of a line you wrote once.

Warning

Every one of these is a parsing versus matching defect: treating a structured identifier as a string and testing it with substring operations. The same defect class produces OAuth redirect_uri bypasses and open redirects. Compare origins by parsing them into scheme, host and port and comparing the parts — or, better, by exact match against an enumerated list.

Vary, and the bug that connects two articles

If your Access-Control-Allow-Origin varies by request — any allowlist does — the response is no longer a single cacheable artifact. The spec's own worked explanation is about breakage rather than attack:

consider what happens if Vary is not used and a server is configured to send Access-Control-Allow-Origin for a certain resource only in response to a CORS request. When a user agent receives a response to a non-CORS request for that resource… the response will lack Access-Control-Allow-Origin and the user agent will cache that response.

The security reading is mine, and it is the mechanism from the caching article wearing different clothes: a shared cache that stores a response keyed without Origin can serve one visitor's Access-Control-Allow-Origin to another. Where that article showed a nonce leaking across users, this is an allowlist entry leaking across users.

Important

The instruction is conditional, and people get the conditional backwards. Use Vary: Origin when the header varies. Do not use it when Access-Control-Allow-Origin is a constant — * or a single static origin — because then it fragments your cache for nothing. The spec says so explicitly: in the static case, "always send" the header and "do not use Vary."

Where it came from

Standards histories can be dull. The CORS history is not, and it is worth the detour because the shape of the mechanism only makes sense once you know it.

CORS did not begin as a browser security feature. It began in 2005 as Authorizing Read Access to XML Content Using the <?access-control?> Processing Instruction — a W3C Working Group Note by Matt Oshry and Brad Porter of Tellme Networks and RJ Auburn of Voxeo. Its motivation was voice browsers:

This Note describes one mechanism in use by voice browser vendors to allow XML content providers to specify which application domains can access their XML content.

An XML processing instruction, for telephony systems, so a VoiceXML application could read a weather feed from another domain.

Anne van Kesteren joined as an editor in February 2007, by which point the title had become Enabling Read Access for Web Resources. The name "Cross-Origin Resource Sharing" first appears in a March 2009 draft, and it reached Recommendation in January 2014 — after which it was folded into the Fetch Standard, where it lived on 2026-08-12. Fetching w3.org/TR/cors/ redirects there.

Note

Two things that history explains. The phrase read access was in the title for nine years, which is the answer to what CORS governs. And an eighteen-year-old mechanism designed for XML feeds is why the safelist looks the way it does — it had to accommodate a web that already existed, twice over.

Where CORS simply does not apply

Worth knowing the edges, because they are where people assume protection that was never there.

The default request mode is not CORS. From the spec: "Unless stated otherwise, it is no-cors" — and the spec is candid about the consequence: "Even though the default request mode is no-cors, standards are highly discouraged from using it for new features. It is rather unsafe." This is the mode <img>, <script>, <link> and form submissions ride, and it is why they cross origins without any CORS check happening.

WebSockets are exempt entirely. The handshake sends an Origin header and the browser enforces nothing about it. RFC 6455 puts the obligation on you:

Servers that are not intended to process input from any web page but only for certain sites SHOULD verify the |Origin| field is an origin they expect.

A SHOULD, addressed to the server. That is the whole of cross-site WebSocket hijacking as a bug class: the browser hands over an origin claim and then declines to act on it.

Preflights do not follow redirects. A preflight succeeds only on a 2xx with a passing CORS check; a 3xx is a network error. If your API redirects OPTIONS — to add a trailing slash, say — every preflighted cross-origin request against it fails, and the console message will not mention redirects.

Lessons

  • CORS shares, it does not restrict. No headers is the most restrictive state. Every header you add opens something.
  • The side effect precedes the check. For requests that need no preflight, the server has already acted by the time the browser withholds the response — which is why CSRF needs its own defense.
  • * with credentials is unreachable, not forbidden. Step 3 is skipped and step 4 is byte equality. Send the exact origin.
  • null is a real origin any page can obtain. Allowlisting it allowlists everyone.
  • The wildcard excludes Authorization by name, even without credentials.
  • The preflight cache cannot be invalidated. Access-Control-Max-Age is a ceiling set at write time; there is no purge.
  • The safelist is archaeology, not a safety judgment — it is what a form could already do.
  • Origin is trustworthy because it is forbidden to script, along with everything prefixed Sec-.
  • Most CORS bugs are string matching where parsing was needed, which is the same defect that breaks OAuth redirect validation.

Practice

References

  1. Anne van Kesteren. “Fetch Standard.” WHATWG. — the CORS check, the preflight cache, forbidden request headers, and the no-cors default
  2. MDN. “Cross-Origin Resource Sharing.” MDN. — the simple-request conditions and the warning against allowlisting null
  3. James Kettle. “Exploiting CORS Misconfigurations for Bitcoins and Bounties.” PortSwigger Research, 2016. — origin reflection, null whitelisting, prefix and suffix matching failures, and protocol bypass
  4. Matt Oshry, Brad Porter, RJ Auburn. “Authorizing Read Access to XML Content Using the access-control Processing Instruction.” W3C Working Group Note, 2005. — where CORS actually began — a voice-browser mechanism for cross-domain XML
  5. Anne van Kesteren. “Cross-Origin Resource Sharing.” W3C Recommendation, 2014. — the Recommendation, since folded into Fetch
  6. Ian Fette, Alexey Melnikov. “The WebSocket Protocol.” RFC 6455, 2011. — why the origin check on a WebSocket handshake is the server's job and nobody else's
  7. MDN. “Access-Control-Allow-Origin.” MDN. — the null-origin warning, verbatim

How to cite

APA
Mangalapilly, Y. J. (2026, August). The Header That Grants Nothing. Saṃhitā Notes. https://yesudeep.com/blog/the-header-that-grants-nothing/
BibTeX
@online{mangalapilly2026the,
  author  = {Yesudeep Jose Mangalapilly},
  title   = {The Header That Grants Nothing},
  journal = {Sa\d{m}hit\=a Notes},
  year    = {2026},
  month   = {August},
  url     = {https://yesudeep.com/blog/the-header-that-grants-nothing/},
  urldate = {2026-08-12},
}
Plain
Yesudeep Jose Mangalapilly. “The Header That Grants Nothing.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-header-that-grants-nothing/.
RIS
TY  - ELEC
AU  - Mangalapilly, Yesudeep Jose
TI  - The Header That Grants Nothing
T2  - Saṃhitā Notes
PY  - 2026
UR  - https://yesudeep.com/blog/the-header-that-grants-nothing/
Y2  - 2026-08-12
ER  - 

Annotations

Thank you — your note is held for review and will appear once approved.

Thank you — your note is published.

Please sign in below to leave a note.