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, orPOST. - The only headers set by the page are on the safelist:
Accept,Accept-Language,Content-Language,Content-Type, andRange. - If
Content-Typeis set, its value is one of exactly three:application/x-www-form-urlencoded,multipart/form-data, ortext/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/jsonis 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/plaintrick 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
# 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
// 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.okis 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
onloadversusonerrorfires, 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.
Related
The browser attaches your credentials to any request an attacker can cause. The server sees a valid session and cannot tell who asked for it.
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
mode: 'no-cors', credentials: 'include', the safelisted-header guard, and the opaque response.
Reflected Access-Control-Allow-Origin with credentials, null origin allowlists, and the upgrade from a blind write to read-and-write.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Cookies are scoped by domain, not origin: who can set one, who receives one, and what __Host-, Secure, Path, and Partitioned actually change.