Origin, Referer, and Fetch Metadata
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Headers the page cannot forge
The browser knows something the server does not: which page caused this request. It reports that in headers a page is forbidden from setting — Origin, Referer, and the Sec-Fetch-* family are all on the forbidden-header list, so no amount of fetch options or form markup can override them.
That makes them a genuine defence, and a cheap one. A single middleware rejecting cross-site state changes protects every route at once, with no tokens to mint, embed, store, or rotate.
The reason this is not the only defence is a chain of edge cases — proxies that strip headers, Origin: null, and the fact that a same-site attacker looks legitimate to all of them. Those are covered below and in Beating Referer and Origin Checks.
In 2026 the recommendation is: use Fetch Metadata as the primary header check, fall back to Origin, and keep a token underneath. Sec-Fetch-Site is supported in every current browser and is far easier to get right than Referer parsing.
The Sec-Fetch-* headers
A Resource Isolation Policy
// The standard Resource Isolation Policy, applied before any route handler.
// Rejects cross-site state changes for the whole application at once.
function resourceIsolation(req, res, next) {
const site = req.get('Sec-Fetch-Site')
// Older browsers and non-browser clients send nothing. Allowing the request
// through here is a deliberate choice: it keeps curl, mobile apps, and
// server-to-server callers working. It is ALSO the bypass -- so this policy
// must sit alongside a token, not replace it. If your clients are all
// modern browsers, reject instead and the defence becomes complete.
if (!site) return next()
// same-origin: the app itself. none: the user typed it or used a bookmark.
if (site === 'same-origin' || site === 'none') return next()
// same-site means a subdomain. Allow it only if you actually trust every
// subdomain -- if any of them hosts user content or could be taken over,
// treat this as cross-site. See the cookie-tossing guide.
if (site === 'same-site') return next()
// Everything left is cross-site. Safe methods are fine; anything that
// changes state is not.
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next()
return res.status(403).json({ error: 'cross-site request rejected' })
}
app.use(resourceIsolation)Checking Origin correctly
function checkOrigin(req) {
const origin = req.get('Origin')
// Bug 1: a missing Origin passes. An attacker who can suppress the header
// -- via a same-origin redirect chain, or on shapes that do not send it --
// bypasses the check entirely.
if (!origin) return true
// Bug 2: substring matching. All of these pass:
// https://app.example.com.evil.com
// https://evil.com/?x=https://app.example.com
// https://notapp.example.com
if (origin.includes('app.example.com')) return true
// Bug 3 (the startsWith variant, equally common):
// https://app.example.com.evil.com passes startsWith too
return false
}The Origin: null case
Origin: null is not a missing origin — it is a real value the browser sends from an opaque origin, and an attacker can produce it on demand. The situations that yield it:
- A sandboxed iframe without
allow-same-origin. - A
data:URL. - A local
file://page. - Some cross-origin redirect chains.
So null must never appear in an allowlist. Treat it exactly like any other untrusted origin. The same warning applies to CORS: Access-Control-Allow-Origin: null with credentials is directly exploitable, because the attacker can make their page's origin be null.
The attacker's version is one attribute:
<iframe sandbox="allow-scripts allow-forms" srcdoc='
<form id="p" action="https://app.example/account" method="POST">
<input name="email" value="attacker@evil.example">
</form>
<script>document.getElementById("p").submit()</script>
'></iframe>
Note that the sandbox costs the attacker the cookie in most configurations — the request is a subresource navigation from an opaque origin — so this is primarily useful against SameSite=None targets and against non-cookie auth. See Beating Referer and Origin Checks.
Why Referer is the weakest of the three
Referer was the original header-based defence and it is the one to rely on least:
- It is frequently absent. A
Referrer-Policyofno-referrersuppresses it, users and privacy extensions strip it, and HTTPS-to-HTTP navigations drop it by default. An application that requires it will break for real users; an application that allows the missing case has an easy bypass. - It carries the full URL, so a token in a query string leaks to whatever the user navigates to next. That is a good reason never to put a token in a URL.
- Its policy is partly attacker-controlled. The attacker's page sets its own
Referrer-Policy, so it can choose to send a full referrer (unsafe-url) when that helps defeat a check. - Parsing it is error-prone. Every substring-matching bug in the vulnerable example above originated as
Refererparsing.
If you must use it, parse it with a URL parser and compare .origin for exact equality. Prefer Origin, and prefer Sec-Fetch-Site over both.
Checklist
- Prefer
Sec-Fetch-Site. One header, four values, no parsing. Supported everywhere current. - Decide the missing-header case deliberately and write down why. Allowing it keeps non-browser clients working and is the documented bypass; denying it is the stronger position for a browser-facing route.
- Exact-match full origins. Never
includes, neverstartsWith, never a regex over a hostname. - Never allowlist
null. - Decide whether
same-siteis trusted. If any subdomain hosts user content or could be taken over, it is not — see Subdomain Cookie Injection. - Keep a token underneath. Header checks fail open in exactly the cases you cannot enumerate; the token does not. Defense in Depth covers the layering order.
Related
Absent-header fallbacks, startsWith and contains matching, Origin: null from a sandboxed frame, and unsafe-url to restore a full Referer.
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
CORS gates reading, not sending. Simple versus preflighted requests, opaque responses, and why write-without-read is enough for CSRF.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.
net/http.CrossOriginProtection in Go 1.25, gorilla/csrf for token-based protection, and why forgetting to wrap the mux is the whole risk.
Reflected Access-Control-Allow-Origin with credentials, null origin allowlists, and the upgrade from a blind write to read-and-write.