AJAX Security: CSRF and CORS
The same-origin policy, CORS preflight requests explained concretely, CSRF attacks, and CSRF tokens.
The same-origin policy: the browser's default restriction
An origin is the combination of scheme, host, and port (https://app.example.com:443). By default, the browser's same-origin policy blocks JavaScript running on one origin from reading a response returned by a different origin — https://app.example.com can't read a response from https://api.other-example.com unless that other origin explicitly says it's allowed to.
This is a narrower restriction than it sounds, and the narrowness is exactly why two separate security mechanisms — CORS and CSRF protection — both exist and solve different halves of the problem:
- The same-origin policy blocks reading the response of a cross-origin request.
- It does not, by itself, block the browser from sending many kinds of cross-origin requests in the first place — a "simple" cross-origin
POSTgoes out over the wire whether or not the calling page is ever allowed to see what came back.
That gap — a request can fire even when its response can't be read — is precisely what makes CSRF attacks possible, and precisely what CORS does not protect against.
CORS: relaxing the same-origin policy for reads
CORS (Cross-Origin Resource Sharing) is the mechanism a server uses to opt certain origins into being allowed to read its responses. For a "simple" request (a GET/POST/HEAD with only a small set of allowed headers and body types), the browser just sends it and then checks the response's headers before handing the result to your JavaScript. For anything else — including a POST with a JSON body, which is the overwhelming majority of real AJAX calls — the browser first sends an automatic preflight request to ask permission.
fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item: 'Widget' }),
});
Because this uses Content-Type: application/json (not one of the handful of "simple" content types), the browser sends this first, entirely on its own, before your actual request ever reaches the server's application code:
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
The server must respond, explicitly granting permission:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
Only if that preflight response grants it does the browser send the real POST at all — a rejected or missing preflight response means the actual request is never sent to the server's application code in the first place. Access-Control-Max-Age lets the browser cache that answer (here, 24 hours) so it doesn't need to re-ask on every subsequent request to the same endpoint.
| Simple request | Preflighted request | |
|---|---|---|
| Methods | GET, HEAD, POST only |
Any method (PUT, DELETE, PATCH, etc.) |
| Headers | A small allowed set (Accept, Accept-Language, etc.) |
Any custom header (Content-Type: application/json, Authorization, etc.) |
| Body content type | text/plain, multipart/form-data, application/x-www-form-urlencoded |
application/json and most anything else |
| Extra round-trip? | No | Yes — an OPTIONS preflight before the real request |
CSRF: the attack CORS doesn't stop
Cross-Site Request Forgery exploits exactly the gap described above: a browser will still send a cross-origin request carrying the victim's cookies, even though CORS will stop the attacker's page from reading the response. If the request itself has a side effect, the damage is already done before any response is ever read.
Concretely: a user is logged into bank.com, with a session cookie the browser will automatically attach to any request to bank.com. The user then visits a completely unrelated, malicious page, which silently auto-submits a hidden form:
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="1000">
</form>
<script>document.forms[0].submit();</script>
The browser dutifully sends this POST to bank.com, attaching the victim's real session cookie — because as far as the browser is concerned, it's just a form submission to bank.com, and cookies are attached per-origin regardless of which page triggered the request. bank.com processes the transfer. The attacker's page never needed to read any response at all for the attack to succeed, which is exactly why a CORS policy — however strict — provides no protection here; CORS only ever governed whether the response could be read, not whether the request would be honored.
CSRF tokens: the fix
The standard defense is a CSRF token: the server embeds a random, unpredictable, per-session (or per-form) value into the page it renders for a legitimately logged-in user, and requires every state-changing request to include that exact token back:
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ to: 'friend-account', amount: 50 }),
});
The attacker's page cannot read bank.com's legitimately rendered HTML at all — that's exactly what the same-origin policy blocks — so it has no way to discover a valid token value to include in its forged request. Without a matching token, the server rejects the request outright, regardless of whether a valid session cookie came along with it.
SameSite cookies are a complementary, more modern defense that works at the cookie level instead of the request-body level: a cookie marked SameSite=Lax or SameSite=Strict simply isn't attached by the browser to most cross-site requests in the first place, closing much of the CSRF hole without any token machinery at all. Many frameworks now combine both — SameSite cookies as a strong default, plus explicit CSRF tokens for defense in depth.
| Same-origin policy | CORS | CSRF protection (tokens / SameSite) |
|
|---|---|---|---|
| What it governs | Whether JS can read a cross-origin response | Which origins a server explicitly permits to read its responses | Whether a state-changing request is honored at all |
| Default behavior | Reads blocked by default | Nothing allowed until the server opts in | Nothing enforced unless the app implements it |
| Stops CSRF? | No — requests still get sent | No — a lenient CORS policy doesn't create a CSRF hole, and a strict one doesn't prevent one | Yes — that's specifically what it's for |
Common mistakes
- Believing a strict CORS configuration protects against CSRF — it doesn't; CORS governs whether a response can be read, and a CSRF attack never needs to read the response to do damage.
- Believing a permissive CORS configuration (
Access-Control-Allow-Origin: *) by itself creates a CSRF vulnerability — it doesn't either; CSRF risk comes from missing anti-CSRF protections (tokens,SameSitecookies), not from a CORS header. - Assuming every cross-origin request triggers a preflight — only "non-simple" ones do; a plain cross-origin
GETor a form-encodedPOSTgoes out immediately with noOPTIONSround-trip at all. - Setting
SameSite=Noneon a cookie that genuinely needs to be sent cross-site (e.g., a legitimate embedded widget) without also marking itSecure— browsers requireSecurealongsideSameSite=None, and will silently drop the cookie otherwise.