Beating Referer and Origin Checks
Absent-header fallbacks, startsWith and contains matching, Origin: null from a sandboxed frame, and unsafe-url to restore a full Referer.
Two questions to answer first
Header-based CSRF defence fails in two ways, and it is worth establishing which one you are looking at before trying anything else:
- What happens when the header is absent? If the check allows the request through, you only need to suppress the header. If it denies, you need to forge a value that passes.
- How is the value compared? Exact match against an allowlist is sound.
includes,startsWith,endsWith, and hand-written regexes are all bypassable, usually trivially.
Test the first with a proxy: delete Referer, replay, see what happens. Then delete Origin. Then both. That is three requests and it tells you which half of this guide applies.
Note that a browser will not let you delete these headers from an attacker page — the techniques below are about making the browser omit them, which is a narrower set of options.
Suppressing the headers
The GET case is the easy win
This deserves its own note because it is so often overlooked. Browsers do not send Origin on a plain top-level GET navigation. It is sent on POST and on CORS requests, but a normal link click or location = ... carries no Origin header at all.
So an application whose sole CSRF defence is an Origin check, applied to a route that also accepts GET, has no defence on that route. The header the check depends on simply is not there, and the check's missing-header branch decides the outcome.
Combined with the fact that SameSite=Lax also permits top-level GET, this makes "does the endpoint accept GET?" the single highest-value question in CSRF testing. Ask it first.
Sec-Fetch-Site does not have this weakness — it is sent on every request including safe navigations, which is one of the main reasons to prefer it. See Origin, Referer, and Fetch Metadata.
Matching flaws
// Target origin: https://app.example.com
// 1. includes() -- the attacker puts the string anywhere.
if (origin.includes('app.example.com')) allow()
// https://app.example.com.evil.com passes
// https://evil.com/?r=https://app.example.com passes (as a Referer)
// https://notapp.example.com passes
// 2. startsWith() -- suffix is unconstrained.
if (origin.startsWith('https://app.example.com')) allow()
// https://app.example.com.evil.com passes
// https://app.example.com@evil.com passes as a Referer (userinfo)
// 3. endsWith() -- prefix is unconstrained.
if (origin.endsWith('example.com')) allow()
// https://evil-example.com passes
// https://appexample.com passes
// 4. An unanchored or unescaped regex. The dots match any character and
// there is no anchor at either end.
if (/app.example.com/.test(origin)) allow()
// https://appXexampleYcom.evil.com passes
// 5. Hostname extracted by splitting rather than parsing.
const host = origin.split('//')[1].split('/')[0]
if (host.endsWith('.example.com')) allow()
// https://evil.com\\.example.com may pass depending on the parser
// Parser differentials between the check and the router are their own
// class of bug -- always compare with a real URL parser.
// The fix is always the same: parse with new URL() and compare .origin for
// exact equality against an allowlist. An origin is an opaque string.Origin: null
<!-- A sandboxed iframe without allow-same-origin has an OPAQUE origin, so
the browser sends the literal string "null" as the Origin.
This matters because "null" ends up on allowlists surprisingly often --
developers see it in logs from local file:// testing and add it to make
their own debugging work.
Note the trade-off: the sandbox costs you the cookie in most cases,
because the request is a subresource navigation from an opaque origin.
This is primarily useful against SameSite=None targets, against
non-cookie ambient auth, or where the check is the only defence and the
cookie arrives by another route. -->
<iframe sandbox="allow-scripts allow-forms" srcdoc='
<form id="poc" action="https://app.example.com/account/email" method="POST">
<input type="hidden" name="email" value="attacker@evil.example" />
</form>
<script>document.getElementById("poc").submit()</script>
'></iframe>Restoring a Referer with unsafe-url
The mirror image of suppression. If the check requires a Referer and denies when it is missing, the attacker's problem is that a modern default referrer policy may send only the origin, or nothing at all on a downgrade.
The attacker's page controls its own policy, so it can opt into sending the full URL:
<meta name="referrer" content="unsafe-url">
This matters when the check does substring matching on the path as well as the host. With unsafe-url the attacker can put the expected string into their own URL:
https://evil.example/csrf.html?https://app.example.com/account
A check doing referer.includes('app.example.com') now passes, because the target's origin appears in the attacker's query string. Combined with a permissive host match, this is the standard way a Referer-only defence falls.
An attacker can also host the payload at a path designed to satisfy the check — https://app.example.com.evil.example/ for a startsWith, or a subdomain named to satisfy an endsWith.
Proxies and infrastructure
Worth checking on any assessment with a CDN, WAF, or reverse proxy in front of the application, because the header the app sees may not be the header the browser sent:
- Headers stripped in transit. Some proxies remove
OriginorRefererfor privacy or caching reasons. The application's missing-header branch then decides every request, and the team may not know it is being taken. - Headers rewritten. A proxy that normalises
Originto its own hostname makes every request look same-origin. This silently disables the defence entirely. X-Forwarded-*trusted blindly. If the app derives its own origin fromX-Forwarded-Hostand compares it toOrigin, and the proxy does not overwrite that header, an attacker sets both and the comparison passes.- Inconsistent normalisation. The WAF and the application may parse a URL differently — trailing dots, uppercase hosts, IDN, backslashes. A parser differential lets a request look same-origin to one and cross-origin to the other.
Test by sending the request with a spoofed X-Forwarded-Host and by observing, from a request-logging endpoint, exactly which headers survive the path from browser to application.
Prevention
- Prefer
Sec-Fetch-Site. Four fixed values, sent on every request including safe navigations, nothing to parse. Noincludes, no regex, nonullcase. - Exact-match full origins against an allowlist, parsed with a URL parser. Never substring-match, never build the comparison from string operations.
- Never allowlist
null. If local development needs an exception, gate it behind an environment flag that cannot be set in production. - Decide the missing-header case explicitly, write down the reason, and prefer default-deny for browser-facing state changes.
- Do not derive your own origin from a request header unless the proxy is known to overwrite it.
- Do not rely on
Refereras the primary signal. It is absent too often to require and too malleable to trust. - Keep a token underneath. Every technique on this page is defeated by a synchronizer token, because none of them lets the attacker read the target's HTML.
Related
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Reflected Access-Control-Allow-Origin with credentials, null origin allowlists, and the upgrade from a blind write to read-and-write.
Lax top-level GET, Chromium's 120-second Lax+POST window, on-site redirect gadgets against Strict, and schemeful downgrade.
Token not checked, checked only when present, empty accepted, not tied to the session, reused across users, or predictable.
img, script, link, scripted link clicks, and top-level navigation — and why only the last one still carries a cookie.
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.