The Sec-Fetch-Site header, and what breaks it
Browsers attach a Sec-Fetch-Site header to outgoing requests that page JavaScript can't read or override, and some servers use it to tell a same-site call from a cross-site one without a CSRF token. We looked at what our own proxy does with that header on its way to an upstream API, and the answer is a case study in why a header can be technically present and still not mean what the server thinks it means.
What Sec-Fetch-Site actually carries
Sec-Fetch-Site is one of four Fetch Metadata headers Chromium and Firefox attach to requests: the others are Sec-Fetch-Mode, Sec-Fetch-Dest and Sec-Fetch-User. All four start with Sec-, which marks them as forbidden request headers: a page's own fetch() or XMLHttpRequest call cannot set or override them. The browser computes the value itself from the relationship between the page that's making the request and the URL it's requesting.
Sec-Fetch-Site takes one of a handful of values: same-origin (same scheme, host and port), same-site (same registrable domain, different subdomain or port), cross-site (an unrelated site), or none (the user typed the URL, followed a bookmark, or otherwise triggered the request directly rather than a page). Unlike Origin, which browsers only attach to some request types, Sec-Fetch-Site is sent on navigations too, which is most of why servers reach for it.
Why servers use it as a CSRF check
A classic CSRF attack relies on a victim's browser automatically attaching their session cookie to a request that a different site triggered, for example an auto-submitting form on attacker.example that posts to bank.example/transfer. Origin and Referer can both be used to catch this, but each has gaps: some proxies and privacy settings strip Referer, and older clients or specific request shapes can omit Origin. Because Sec-Fetch-Site can't be set by the page itself and is present on the request, a server can reject a state-changing request where it reads cross-site as a second line of defense, independent of whether a CSRF token was also checked.
That only works as a defense if the value a server receives genuinely describes the hop that reached it. That assumption is exactly what a proxy sitting between the browser and the server can break.
What corsproxy.dev does with it
Our proxy strips a short, explicit list of headers from the browser's request before it calls the upstream: Host, Origin, Referer, Authorization, Cookie and X-API-Key. We read through that code to answer this question, and everything outside that list is carried through unchanged; Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest are not on it. So if your frontend's fetch() call to corsproxy.dev carried Sec-Fetch-Site: cross-site, the request we make to your upstream API carries the same header with the same value.
The problem is which hop that value describes. The browser computed Sec-Fetch-Site for the request it actually sent: from your page's origin to corsproxy.dev. corsproxy.dev is, almost always, cross-site to both your page and your upstream API, so the value is cross-site for the hop the browser made. When we forward that same header string on the server-to-server call we make to your API, nothing recomputes it for the new hop; it arrives looking like a fact about the request that reached your server, when it's really a fact about the request that reached ours. Your API can't tell, from the header alone, that the request was relayed at all.
This isn't specific to us. Any reverse proxy, API gateway, or CORS proxy that forwards request headers without rewriting them has the same property: whatever Sec-Fetch-Site said about the browser's hop to the proxy is what the origin server sees, not a value computed for the proxy's own hop to the origin.
What this means if you're the one running the API
- Don't treat
Sec-Fetch-Siteas proof a request came straight from your frontend. It tells you about the immediate caller of whoever made the request your server received, which might be a browser, or might be any process re-issuing requests on a browser's behalf. - It's still useful as one signal among several. A request with no
Sec-Fetch-Siteheader at all is from a non-browser client (plaincurl, a backend job, most HTTP libraries don't set it), which is a legitimate thing to know even if you can't fully trust the value when it is present. - For anything that actually needs to verify "this call came through my proxy and nowhere else," use a secret the proxy adds, not a header the browser set. A shared header value the proxy injects server-side, which the caller's JavaScript never sees and can't forge, verifies the hop directly instead of inferring it from metadata computed for a different hop.
corsproxy.dev's version of that last point is managed upstream headers: a secret header stored against your API key and a target host, added to the outbound request after everything else, so neither the caller's JavaScript nor a request parameter can see or override it. It solves a different problem than Sec-Fetch-Site (authenticating to your upstream, not classifying the browser's original request), but it's the shape of solution that survives being relayed, which a browser-set metadata header does not.
Checking what your own setup forwards
If you run anything between a browser and an API, whether that's a CORS proxy, an API gateway, or a backend-for-frontend, it's worth checking what it does with these headers rather than assuming. Add a route that echoes request headers back, and compare what arrives at that route with what arrives at the thing behind it:
curl -s -H "Sec-Fetch-Site: cross-site" -H "Sec-Fetch-Mode: cors" \
https://your-proxy.example/echo-headers
If your upstream logs request headers, check whether the same values show up there unchanged. If they do, and anything downstream makes a trust decision based on them, that decision is trusting a value computed for a hop it never saw.