Fetch Metadata request headers
Browsers attach Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest to every request, describing where it came from and what it is for. A server that reads them can reject cross-site requests it never intended to serve, before any handler runs.
What it is
Fetch Metadata is a set of request headers the browser attaches to every outgoing request, describing the context the request was made in. The server never has to ask for them and a page cannot forge them — the Sec- prefix makes them forbidden request headers, so script cannot set or change them.
Three of them carry the useful signal:
Sec-Fetch-Site— the relationship between the initiator and the target:same-origin,same-site,cross-site, ornone(the user typed the URL or used a bookmark).Sec-Fetch-Mode— how the request was made:navigate,cors,no-cors,same-origin,websocket.Sec-Fetch-Dest— what the result will be used as:document,image,script,style,iframe,empty, and so on.
A fourth, Sec-Fetch-User, is sent only on navigations triggered by real user activation. It is the odd one out: Safari has never shipped it, so a rule that requires it will misjudge every Safari visitor.
GET /account/delete HTTP/1.1
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image
That request came from someone else’s page, as an <img> tag, pointed at a state-changing endpoint. No legitimate use of your site produces it.
These are request headers you read, not response headers you set. That is the point most people get backwards on first contact. Every other entry in this category — CSP, frame-ancestors, X-Content-Type-Options — is something you send and the browser enforces. Fetch Metadata inverts that: the browser sends, and nothing happens unless your server acts on it. Adding it to a headers config file does nothing at all.
Why it matters
Cross-site attacks work by getting the visitor’s own browser to make a request the visitor did not intend — a hidden form that posts to your /settings endpoint, an <img> whose src is your /logout URL, a <script> pointed at a JSON endpoint to read its contents. The requests are indistinguishable from real ones at the network level, because they are real: same session cookie, same IP, same user.
Fetch Metadata makes them distinguishable. A password change submitted by your own form arrives as Sec-Fetch-Site: same-origin; the same request smuggled in from evil.example arrives as cross-site. One line of server-side logic separates them, and it separates them for every endpoint at once — including the endpoint someone adds next month and forgets to protect.
That “every endpoint at once” property is what makes it worth the effort. Per-endpoint defences fail by omission: a CSRF token protects the forms that have one. A resource isolation policy applied in middleware protects everything behind it by default, so a new route is safe before anyone reviews it.
Support is effectively universal for the three headers that matter — Chrome and Edge since 2020, Firefox since 90, Safari since 16.4 — which is why OWASP now names Fetch Metadata as a primary CSRF defence rather than an experimental extra.
This site does not ship a resource isolation policy. It is static and cookieless: there is no session to ride, no state-changing endpoint, and the one endpoint that does accept POST — /reports, for browser policy reports — is supposed to receive requests the user never initiated. There is nothing here for the policy to protect.
How to implement
Apply the check in one place — middleware, a reverse proxy, an edge function — so it covers every route rather than the routes someone remembered.
Reject the request when all of the following hold:
Sec-Fetch-Siteis present (absent means an older client or a non-browser agent — fail open, or you breakcurl, monitoring, and server-to-server calls).Sec-Fetch-Siteiscross-site.Sec-Fetch-Modeis notnavigate, or the method is notGET— a plain cross-site link to one of your pages is normal traffic and must keep working.Sec-Fetch-Destis notobjectorembed— those two are how a cross-site navigation gets weaponised, so block them even when the mode isnavigate.
Everything else — same-origin, same-site, and none — is allowed.
Then carve out the endpoints that are meant to be reached cross-site, and only those: CORS APIs, public embeds, OAuth callbacks, webhook receivers, report collectors, and anything you serve to other people’s pages on purpose. These exemptions are the part worth reviewing, because each one is a hole you have deliberately left.
Roll it out in report-only first. Log what the policy would have blocked for a week and read the list before enforcing; a legitimate integration you had forgotten about is far more likely to show up than an attack.
Common mistakes
- Rejecting requests that have no
Sec-Fetch-Siteheader. Non-browser clients send none. Fail open on absence and let your other defences handle those. - Blocking
cross-sitenavigations outright. Every inbound link from another site isSec-Fetch-Site: cross-sitewithSec-Fetch-Mode: navigate. Block those and your site becomes unreachable from search results. - Treating
same-siteas safe when it is not.same-siteincludes every subdomain of your registrable domain. If you host untrusted content on one — user pages, a legacy app, a sandbox — it clears the check. Compare againstsame-originfor anything that matters. - Requiring
Sec-Fetch-User. Safari does not send it. A rule that demands it locks out Safari users entirely. - Dropping CSRF tokens once the policy is live. Fetch Metadata is defence in depth alongside
SameSitecookies and tokens, not a replacement for either. - Applying the check after the handler has already run. The value is rejecting before any work happens; a check in the controller has already paid for the database call.
Verification
Send a request that mimics the attack and confirm it is refused:
curl -sI https://example.com/account/settings \
-H 'Sec-Fetch-Site: cross-site' \
-H 'Sec-Fetch-Mode: no-cors' \
-H 'Sec-Fetch-Dest: image'
Expect a 4xx. Repeat with Sec-Fetch-Site: same-origin and expect the normal response. Then check the two cases that must keep working: a plain cross-site navigation (Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: navigate, GET) and a request with no Sec-Fetch-* headers at all — both should be served.
In DevTools, the request headers panel shows the values the browser actually sent, which is the ground truth when a rule misfires.
Related topics
Sources & further reading
- Fetch Metadata Request Headers — W3C Web Application Security Working Group
- Cross-Site Request Forgery Prevention Cheat Sheet — Fetch Metadata headers — OWASP
- MDN — Fetch metadata — MDN