Skip to content
CWE-346A07:2021 – Identification and Authentication Failures

Same-Origin Policy vs CORS

CORS gates reading, not sending. Simple versus preflighted requests, opaque responses, and why write-without-read is enough for CSRF.

The sentence that explains everything

The same-origin policy restricts what a page can read, not what it can send.

Internalise that and CSRF stops being confusing. A page on attacker.example is not allowed to read a response from bank.example — but it was never prevented from causing the request. The web has always allowed cross-origin sends: that is what makes <img>, <script>, <link>, and cross-site form posts work at all. Removing that ability would break the web, so it was never removed.

CORS did not change this. CORS is a mechanism for a server to opt in to letting a cross-origin page read a response it would otherwise be denied. It relaxes the read restriction. It adds no send restriction — with one partial exception, the preflight, which is a side effect rather than a design goal.

So the CSRF attacker's position is: I can make the request, I just cannot see the answer. For a state change, seeing the answer is optional.

Simple requests and the preflight

CORS splits cross-origin requests in two. A simple request (the spec calls it a CORS-safelisted request) is sent straight to the server. Anything else gets an OPTIONS preflight first, and the real request is only sent if the preflight response approves it.

A request is simple when all of these hold:

  • The method is GET, HEAD, or POST.
  • The only headers set by the page are on the safelist: Accept, Accept-Language, Content-Language, Content-Type, and Range.
  • If Content-Type is set, its value is one of exactly three: application/x-www-form-urlencoded, multipart/form-data, or text/plain.

That three-value list is the single most load-bearing fact in practical CSRF. It is why:

  • An HTML form is a CSRF weapon — a form can only ever emit those three encodings, so a form request is always simple and never preflights.
  • application/json is not directly attackable — it is not on the list, so it preflights, and the real request never leaves the browser unless CORS explicitly allows it.
  • The text/plain trick exists at all — it is the only way to get a JSON document in front of a server without a preflight. See JSON, text/plain, and GraphQL.

A preflight is not a CSRF defence that anyone designed. It is an accident of CORS that happens to block some shapes.

What a preflight looks like

HTTP
# The page tried: fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, ...})
# The browser sends this FIRST, with no cookies and no body:

OPTIONS /api/account HTTP/1.1
Host: api.example
Origin: https://attacker.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

# The real POST is sent only if the response approves BOTH the origin and
# the header. A server that simply does not implement CORS answers without
# these headers, the browser refuses, and the POST never happens at all.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://attacker.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true

# Only with BOTH of the last two -- an explicit origin echo AND
# Allow-Credentials: true -- does this become exploitable. A wildcard
# Access-Control-Allow-Origin: * is INCOMPATIBLE with credentials; the
# browser rejects that combination, so a wildcard is not the bug people
# often report it as.

no-cors: send without asking

JavaScript
// mode: 'no-cors' tells the browser: I accept that I will not be allowed to
// read this response, so do not bother with CORS at all. No preflight, no
// Access-Control-* requirement. The request goes out with cookies attached.
//
// The cost is the header guard: in no-cors mode the browser SILENTLY DROPS
// any header that is not CORS-safelisted, and restricts Content-Type to the
// three simple values. Setting application/json here does not throw -- it
// just never arrives, and the server sees a text/plain body it may reject.
fetch('https://bank.example/transfer', {
  method: 'POST',
  mode: 'no-cors',
  credentials: 'include',            // without this, NO cookies are sent
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: 'to=attacker&amount=5000',
})
// The returned Response is opaque: status reads as 0, body is null.
// You cannot tell success from failure here -- confirm on the target.

The opaque response

A no-cors fetch resolves with an opaque Response: status is 0, ok is false, headers are empty, and the body is unreadable — regardless of what actually happened on the server. The same is true of an <img> that loads a non-image or an <iframe> pointed at a cross-origin page.

The practical consequences when testing:

  • You cannot confirm the attack from the attacker page. Verify the effect on the target: reload the account page, check the audit log, watch the mailbox. A PoC that logs response.ok is measuring nothing.
  • You cannot use CSRF to read data. Any "CSRF to exfiltrate" claim needs a second mechanism supplying the read — a CORS misconfiguration (CORS-Assisted CSRF), a JSONP endpoint, or XSS.
  • Side channels are limited but real. Load timing, whether an onload versus onerror fires, and frame-count probing can leak a bit or two. Treat these as separate findings, not as CSRF.

For the tester this is a feature, not a limitation: a state change you can trigger blind is still a state change.

Why CORS is not a CSRF defence

This confusion is common enough to be worth stating directly. Teams sometimes believe that because they have not configured CORS, they are safe from CSRF. They are not.

  • A form does not consult CORS at all. An auto-submitting cross-site form has been legal since 1995 and is unaffected by any Access-Control-* header. The most effective CSRF delivery in 2026 bypasses CORS by never engaging it.
  • Absent CORS blocks reads, which the attacker did not need.
  • A misconfigured CORS policy makes things strictly worse, by adding a read primitive to the write primitive.

The converse is also worth knowing: a strict CORS policy does incidentally block the fetch-with-JSON shape, which is why pure JSON APIs that reject non-JSON content types are hard to attack. That is a real mitigation — it just comes from content-type enforcement, not from CORS. Enforce it deliberately: reject any request whose Content-Type is not application/json, and the text/plain smuggle closes too.