Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest and Sec-Fetch-User explained (tested in Chromium)
Browsers add four Sec-Fetch-* headers (Fetch Metadata) to requests so the server can tell where a request came from and what it is for. We logged them for 18 kinds of request in Chromium 153, then blocked cross-site requests with a small policy and checked which ones got through.
1. The four headers
- Sec-Fetch-Site: how the requesting page relates to the target.
same-origin,same-site(same registrable domain and scheme, other origin, e.g. another port or subdomain),cross-site, ornonewhen the user started it (typed URL, bookmark). - Sec-Fetch-Mode: the request mode:
navigatefor page loads,corsfor normalfetch()/XHR,no-corsfor images, scripts andfetch(…, {mode:'no-cors'}). - Sec-Fetch-Dest: where the response will be used:
document,iframe,image,script,empty(fetch/XHR) and others. - Sec-Fetch-User:
?1on navigations the user triggered (click, typed URL). It is left out otherwise; we never saw?0.
2. What Chromium 153 sent, measured
The page making the request was http://localhost:8973. "Same-site" targets were localhost:8974, "cross-site" targets 127.0.0.1:8974.
| Request | Site | Mode | Dest | User |
|---|---|---|---|---|
| URL typed / opened by the browser | none | navigate | document | ?1 |
| Link click, same origin | same-origin | navigate | document | ?1 |
| Link click, other port | same-site | navigate | document | ?1 |
| Link click, other site | cross-site | navigate | document | ?1 |
window.open() from a button click | cross-site | navigate | document | ?1 |
location.href= from a timer (no click) | cross-site | navigate | document | (absent) |
<iframe src> in the HTML | cross-site | navigate | iframe | (absent) |
| Form POST, submit button clicked | cross-site | navigate | document | ?1 |
| Form POST, submitted by script on load | cross-site | navigate | document | (absent) |
fetch() same origin | same-origin | cors | empty | (absent) |
fetch() other port | same-site | cors | empty | (absent) |
fetch() other site | cross-site | cors | empty | (absent) |
fetch(url, {mode:'no-cors'}) | cross-site | no-cors | empty | (absent) |
fetch POST JSON, other site (the preflight) | cross-site | cors | empty | (absent) |
<img> same origin | same-origin | no-cors | image | (absent) |
<img> other site | cross-site | no-cors | image | (absent) |
<script src> other site | cross-site | no-cors | script | (absent) |
Things worth noticing: a different port is still same-site, so Sec-Fetch-Site alone can't separate two apps on one host name. The CORS preflight (OPTIONS) carries the headers too, so a server can reject it before the real POST is ever sent. And a script can navigate cross-site; only the missing Sec-Fetch-User tells you no one clicked.
3. Only sent to trustworthy origins
Chromium only adds the headers when the target URL is potentially trustworthy: HTTPS, or localhost/127.0.0.1. With a host name mapped to 127.0.0.1 over plain http://, a typed URL, a fetch() and an <img> to it carried no Sec-Fetch-* headers at all. A fetch() from that http page to localhost did carry them (cross-site, cors, empty). So test on HTTPS or localhost, and expect the headers to be missing for plain-http sites, old browsers, curl and server-to-server calls.
4. A resource isolation policy (tested)
The common use is to refuse cross-site requests to an API or app, except ordinary link navigations. In Node.js:
function allowed(req) {
const site = req.headers['sec-fetch-site'];
if (!site) return true; // no Fetch Metadata: old browser, curl, http
if (['same-origin', 'same-site', 'none'].includes(site)) return true;
if (req.headers['sec-fetch-mode'] === 'navigate' && req.method === 'GET' &&
!['object', 'embed'].includes(req.headers['sec-fetch-dest'])) return true;
return false; // cross-site fetch, img, script, form POST
}
// in the handler: if (!allowed(req)) { res.writeHead(403); return res.end(); }
With it in place, the same 18 requests gave:
| Allowed (200) | Blocked (403) |
|---|---|
Typed URL; same-origin and same-site links, fetch and img; same-origin form POST; cross-site link click, window.open, script navigation and <iframe> | Cross-site fetch (cors and no-cors), the cross-site POST preflight, cross-site <img> and <script>, cross-site form POST (clicked or scripted) |
Two caveats. Cross-site iframes are allowed by this policy (they are GET navigations); stop framing with X-Frame-Options or frame-ancestors. And because missing headers are allowed, it is defence in depth, not a replacement for CSRF tokens or SameSite cookies. Don't apply it to resources other sites are meant to load (public images, a CDN, an embeddable widget); for those, decide per path. Related: the Origin header and Cross-Origin-Resource-Policy, which lets the browser enforce a similar rule for no-cors loads. If you vary responses on these headers and something caches them, add them to Vary.
5. See them yourself
DevTools → Network → pick a request → Headers → Request Headers. Navigate by typing the URL and then by clicking a link to the same page, and Sec-Fetch-Site changes from none to same-origin.
Frequently asked questions
What does Sec-Fetch-Site: cross-site mean?
The request was started by a page on a different site (different registrable domain or scheme) than the URL being requested, for example a link, image or fetch from another domain.
What is the difference between Sec-Fetch-Mode navigate, cors and no-cors?
navigate is a page or iframe load; cors is a normal fetch() or XHR; no-cors is an img, script or fetch with mode 'no-cors'. In Chromium 153 a cross-site img and script both sent no-cors.
Why is Sec-Fetch-Site same-site when the port is different?
Sites ignore ports. localhost:8973 requesting localhost:8974 sent same-site in our test, while 127.0.0.1 was cross-site.
Why don't I see Sec-Fetch-* headers?
Chromium only sends them to HTTPS or localhost URLs. A plain-http host name got none in our test. curl and server-side clients don't send them either.
When is Sec-Fetch-User sent?
Only on navigations the user triggered, as ?1. A script navigation, script form submit or iframe load sent no Sec-Fetch-User header at all.
Written with AI assistance. Measured on 2026-10-01 in headless Chromium 153.0.8010.12 (Playwright) against two Node.js HTTP servers that logged every Sec-Fetch-* header: localhost:8973 and localhost:8974 (same site, different origin) and 127.0.0.1:8974 (cross-site). A third host name mapped to 127.0.0.1 over plain http tested a non-trustworthy origin. The isolation policy below ran on the same servers and every allow/block result in the table was observed. Other browsers were not tested. PlainHeaders is our extension; DevTools → Network → Headers shows the same request headers, free.