What Cors Actually Protects And What It Doesnt

CORS never protects a form-submitted POST request, only fetch and XHR calls, which is exactly why CSRF tokens are still necessary even when every API call on your site is fully locked down.

July 25, 20267 min read1 / 4

The last post closed this course's CSRF chapter with a promise: the next section drops the assumption that an attacker can never get code running on the target site itself. That's coming, but not yet. First there's a piece of infrastructure I glossed over three chapters ago, back when the same-origin policy post called CORS "the deliberate replacement" for document.domain and moved on without explaining how it actually works.

That post covered what an origin is and why the browser blocks cross-origin reads by default. It never explained how a server grants an exception to that rule, on purpose, safely. That's what CORS actually is, and it's worth understanding properly before this course leaves request forgery behind.

You Don't Hate CORS

Every developer has a story about CORS ruining their afternoon. A fetch call fails, the console fills up with a red error about a missing header, and the instinct is to curse the mechanism itself.

That instinct is aimed at the wrong target. Almost every "CORS is broken" moment is actually a backend team forgetting to configure an allow list, not a flaw in what CORS is trying to do. CORS exists to let a server say, deliberately, which other origins are allowed to read its responses, an actual allow list instead of the free-for-all document.domain used to be.

You don't hate CORS. You hate the lack of it being configured.

What CORS Doesn't Cover, and Why That Matters More

Here's the part that surprised me the most, and it's the reason CSRF tokens are still necessary even in an app where CORS is configured perfectly everywhere.

CORS does not protect a plain HTML form submission. A <form> tag predates CORS by years, and the web can't retroactively change what forms have always been able to do without breaking a huge share of the internet. So a POST fired by a form sails past CORS entirely, untouched, regardless of how tightly the API's headers are locked down.

This is the gap that made every CSRF attack in the last chapter possible in the first place. CORS governs fetch and XHR-style requests. It has never governed forms.

Simple Requests: The Category Forms Fall Into

The reason a form skips CORS comes down to a specific category the spec calls a simple request. A request counts as simple, and skips the preflight check entirely, when it meets two conditions:

  • The method is GET, POST, or HEAD
  • The content type is not application/json (form-encoded and plain text content types stay simple)

A plain HTML form falls squarely into this category, every time. It can only ever send GET or POST, and it can never set Content-Type: application/json on its own. That's not a bug in some particular form somewhere, it's what a form has always been.

The moment a request uses application/json, or any method other than GET, POST, or HEAD, like PUT, PATCH, or DELETE, it stops being simple. The browser sends a real preflight first, and full CORS protection actually applies.

A simple request (form POST, no JSON body) skipping the preflight entirely next to a non-simple request (JSON fetch call) triggering a full OPTIONS preflight and header check. ExpandA simple request (form POST, no JSON body) skipping the preflight entirely next to a non-simple request (JSON fetch call) triggering a full OPTIONS preflight and header check.

A preflight is the browser quietly asking the server a question before the real request goes out: an OPTIONS call carrying the origin making the request and the method it wants to use. The server responds with what it's willing to allow, and only if that answer covers the actual request does the browser let it through.

Reading the Response Headers Correctly

Once a preflight happens, the server's response headers decide what gets through. Each one has a real, sometimes surprising constraint worth knowing before you rely on it.

Access-Control-Allow-Origin can only ever hold one value: a single origin, or a wildcard *. It cannot be a comma-separated list, and it cannot be a wildcard subdomain pattern like *.example.com. If a server needs to allow several specific origins, it has to check the incoming request's Origin header server-side against its own allow list, then echo back that one matching value conditionally, one origin per response, never all of them at once.

Access-Control-Allow-Methods and Access-Control-Allow-Headers narrow what's permitted beyond that. A server can say yes to GET and POST while refusing DELETE, or accept a custom X-Requested-With header while rejecting everything else. The browser enforces whatever the server states here, refusing to let the actual request through if it asks for more than what was granted.

Access-Control-Allow-Credentials has exactly two meaningful states: true, or simply absent. There's no such thing as a meaningful false here, only present or not. And it comes with a hard rule worth remembering on its own:

A wildcard origin and credentials never mix. If Access-Control-Allow-Origin is *, the browser refuses to send cookies along with the request no matter what the credentials header claims, full stop. Loosening the origin check and keeping cookies flowing at the same time simply isn't an option the spec allows.

The _method Override Footgun

There's a real, easy-to-miss mistake worth knowing about, because it's not a contrived edge case, it's a pattern some frameworks actively encourage.

Some older frameworks, and some apps that copy the pattern without knowing why it existed, let a form fake a PUT or DELETE through a hidden _method field or query parameter. Middleware reads that field and swaps the method internally before routing the request.

The problem is that the request still travels over the wire as an actual POST. As far as the browser and CORS are concerned, nothing about this request stopped being simple. It sails right past every PUT and DELETE-specific CORS protection a team might have quietly been relying on instead of a real CSRF token.

Picture a team that configured CORS to allow DELETE only from their own frontend origin, confident that covered them. Then someone added form-based deletion using _method=DELETE for a page that needed to work without JavaScript. Over the wire, that's a POST. It was never checked against the DELETE rule at all, because it never asked to be DELETE as far as the browser could tell.

Nobody did this on purpose. It's the same shape of gap the CSRF chapter opened with: a real HTTP primitive doing exactly what it was always going to do, quietly stepping around a defense built for a different shape of request.

Fetch Metadata Headers: Worth Knowing, Not Worth Over-Engineering

For a genuinely sensitive endpoint, there's one more signal available on the server side, worth knowing exists rather than reaching for by default.

  • Sec-Fetch-Site tells the server whether the request is cross-site, same-site, same-origin, or none, the last one meaning the user typed the URL directly, which is a strong signal the request is legitimate
  • Sec-Fetch-User is present only when a request was triggered by an actual user action, not a script running setTimeout or firing on page load
  • Sec-Fetch-Dest tells the server what kind of context the response is headed into, an iframe, a full document, a worker, and so on

These headers are sent automatically by modern browsers, and a server can check them to reject requests that don't look legitimate for a sensitive action. This isn't the main defense for anything, more a second opinion a server can consult when it's already suspicious.

What This Sets Up

CORS covers a real, meaningful slice of cross-origin protection. It was never meant to cover everything, and the form gap is proof of that by design, not by accident.

None of this touches what happens once an attacker's own code is running inside your origin. That's a different problem entirely, and it's the one this course turns to next.

The Essentials

  1. CORS is a deliberate allow list, not a flaw to resent. Most CORS frustration traces back to a missing server-side configuration, not the mechanism itself.
  2. CORS never protects a plain HTML form submission. Forms predate CORS, which is why CSRF tokens remain necessary even with CORS configured correctly everywhere.
  3. A simple request uses GET, POST, or HEAD without an application/json body, and skips the preflight entirely. A form always falls into this category.
  4. Access-Control-Allow-Origin can only hold one value, single origin or wildcard, never a list. Multiple allowed origins require a server-side check that echoes back the matching one.
  5. Access-Control-Allow-Credentials is either true or absent, and it's incompatible with a wildcard origin. The browser refuses to send cookies to * no matter what this header says.
  6. A hidden _method override still travels as a POST over the wire, which means it silently skips every CORS protection scoped to PUT or DELETE.

Next up: cross-site scripting, the attack that stops asking the browser to send a forged request and instead runs its own code inside your origin.

Further Reading and Watching