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

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.

RequestSiteModeDestUser
URL typed / opened by the browsernonenavigatedocument?1
Link click, same originsame-originnavigatedocument?1
Link click, other portsame-sitenavigatedocument?1
Link click, other sitecross-sitenavigatedocument?1
window.open() from a button clickcross-sitenavigatedocument?1
location.href= from a timer (no click)cross-sitenavigatedocument(absent)
<iframe src> in the HTMLcross-sitenavigateiframe(absent)
Form POST, submit button clickedcross-sitenavigatedocument?1
Form POST, submitted by script on loadcross-sitenavigatedocument(absent)
fetch() same originsame-origincorsempty(absent)
fetch() other portsame-sitecorsempty(absent)
fetch() other sitecross-sitecorsempty(absent)
fetch(url, {mode:'no-cors'})cross-siteno-corsempty(absent)
fetch POST JSON, other site (the preflight)cross-sitecorsempty(absent)
<img> same originsame-originno-corsimage(absent)
<img> other sitecross-siteno-corsimage(absent)
<script src> other sitecross-siteno-corsscript(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.