How HTTP Works

Request header

> Sec-Fetch-Site: cross-site

Sec-Fetch-Site Header: Block Cross-Site Requests

Sec-Fetch-Site says whether a request is same-origin, same-site, cross-site or user-initiated. Resource isolation policy for Express and nginx, plus CSRF use.

Reviewed 5 min readintermediate4 sourcesTry itMarkdown
Direction
Request
Category
Security
Spec
W3C Fetch Metadata
JS can set it
No: forbidden header name
On this page

TL;DR: Sec-Fetch-Site is a request header the browser adds to every HTTPS request, saying whether it came from your own origin, your own site, another site (cross-site), or a direct user action (none). Servers use it to drop cross-site requests that are not plain page navigations, which blocks most CSRF and cross-site data leaks with a few lines of middleware.

What the browser sends

POST /transfer HTTP/1.1
Host: bank.example
Origin: https://evil.example
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded

That is what a victim’s browser sends when a hidden form on evil.example auto-submits to your site. The cookie is attached (depending on SameSite), and the Origin header is there too, but Sec-Fetch-Site gives you a one-token answer instead of making you parse and compare origins.

It is a structured-field token, defined in section 2.3 of the W3C Fetch Metadata spec. It is a forbidden request header, so scripts cannot set it.

Values

ValueMeaning
same-originSame scheme, host, and port as the target URL.
same-siteSame scheme and registrable domain, but a different host or port, for example app.example.com calling api.example.com.
cross-siteAnything else, including http to https on the same domain.
noneNot initiated by a page: the user typed the URL, used a bookmark, dragged a link, or the browser opened it on its own.

Two details that trip people up:

  • Redirects count. The browser evaluates the whole redirect chain. If a request started on evil.example, bounced through example.com, and landed on example.com, the final request is cross-site. If the chain only ever touched the same site it stays same-site, and same-origin requires every hop to match.
  • Scheme matters. Fetch Metadata uses schemeful same-site, so http://example.com to https://example.com is cross-site.

Which browsers send it

From MDN’s compatibility data: Chrome and Edge 76, Firefox 90, Safari 16.4 (March 2023). MDN marks it widely available since March 2023. Anything older, and every non-browser client, sends nothing. That is why every sane policy starts with “if the header is absent, allow”.

It is also only sent to potentially trustworthy URLs: HTTPS, and localhost for development. Behind a TLS-terminating load balancer this is fine, because the browser decides based on the URL it requested, not what reaches your app.

Resource isolation policy

The pattern recommended by web.dev: reject cross-site requests unless they are plain navigations. Public endpoints that are supposed to be embedded or called cross-origin get an explicit exemption.

allow if Sec-Fetch-Site is absent                    # old browsers, curl, webhooks
allow if Sec-Fetch-Site is same-origin, same-site or none
allow if mode is navigate, method is GET, and dest is not object or embed
allow if the path is on your public cross-origin list
otherwise reject with 403

Express

const SAME = new Set(['same-origin', 'same-site', 'none'])
const PUBLIC_PATHS = new Set(['/favicon.png', '/api/public/status'])

function resourceIsolation(req, res, next) {
  const site = req.get('Sec-Fetch-Site')
  if (!site || SAME.has(site)) return next()

  const isSimpleNavigation =
    req.get('Sec-Fetch-Mode') === 'navigate' &&
    req.method === 'GET' &&
    !['object', 'embed'].includes(req.get('Sec-Fetch-Dest'))
  if (isSimpleNavigation || PUBLIC_PATHS.has(req.path)) return next()

  res.set('Cache-Control', 'no-store')
  res.status(403).type('text/plain').send('Cross-site request blocked')
}

app.use(resourceIsolation) // before sessions, auth, body parsing

Put it first. web.dev’s advice is to reject before authentication or any other processing so the response time does not leak information.

nginx

nginx has no if that can combine four variables cleanly, so build one key with map. Regex entries in a map are tried in the order written, and the first match wins.

# $http_sec_fetch_site is empty when the header is absent
map "$http_sec_fetch_site|$http_sec_fetch_mode|$http_sec_fetch_dest|$request_method" $fetch_blocked {
    default                                                      1;
    "~^\|"                                                       0;  # header absent
    "~^(same-origin|same-site|none)\|"                           0;
    "~^cross-site\|navigate\|(document|iframe|frame)\|GET$"      0;  # simple navigation
}

server {
    listen 443 ssl;

    location / {
        if ($fetch_blocked) { return 403; }
        proxy_pass http://app;
    }

    location = /api/public/status {
        proxy_pass http://app;   # intentionally cross-origin
    }
}

if inside location is fragile in general, but if (...) { return ...; } is the one use that is safe. Include Sec-Fetch-Site in Vary if a CDN in front of this caches the responses, otherwise a cached 200 can be served to a request that should have been rejected.

CSRF defence-in-depth

Rejecting cross-site on POST, PUT, PATCH, and DELETE is the part of the policy that matters for CSRF. OWASP’s CSRF cheat sheet describes it as a lightweight way to block obvious cross-site requests, with the caveat that browsers without the header need a fallback such as an Origin check or a token. Treat it as a layer in front of your CSRF tokens and SameSite cookies, not a replacement.

Things the policy breaks if you do not plan for them:

  • Cross-site form POST callbacks. SAML POST binding and OpenID response_mode=form_post end with the identity provider posting a form to your callback. That request is cross-site, navigate, POST. Exempt that path.
  • Webhooks from browsers’ point of view. Server-to-server webhooks carry no header and pass. A browser-based payment return that POSTs back to you does not.
  • Public APIs called with CORS. A fetch() from another origin arrives as cross-site with mode cors. If the API is meant for that, exempt it, and keep CORS headers on it.
  • Hotlinked assets. Images, fonts, and scripts you want third parties to embed arrive as cross-site and no-cors.

Debugging

Check what your own browser sends. In DevTools, open the Network panel, pick a request, and look under Request Headers. From the command line, send what a browser would:

curl -i https://example.com/api/transfer -X POST \
  -H 'Sec-Fetch-Site: cross-site' -H 'Sec-Fetch-Mode: cors' -H 'Sec-Fetch-Dest: empty'

curl lets you set Sec-* headers because the restriction exists only in browser scripts, which is exactly why the header proves nothing about non-browser clients.

For the resource side of the same idea, see Cross-Origin-Resource-Policy, which makes the response declare who may load it. Sec-Fetch-Site is the request-side version, and it is more flexible because the server can decide per request.

Frequently asked questions

What does Sec-Fetch-Site: cross-site mean?

The page or context that triggered the request belongs to a different site (registrable domain plus scheme) than the URL being requested. A form on evil.example posting to bank.example, or an img tag on a blog loading your image, both arrive with Sec-Fetch-Site: cross-site. If the request passed through a redirect chain, the browser sends cross-site when any URL in the chain was cross-site.

What is the difference between same-origin and same-site in Sec-Fetch-Site?

same-origin requires identical scheme, host, and port. same-site only requires the same scheme and registrable domain, so app.example.com calling api.example.com is same-site, and so is https://example.com calling https://example.com:8443. Because the scheme is part of the comparison, an http page calling an https endpoint on the same domain is cross-site.

Why is Sec-Fetch-Site missing from my request?

Browsers only attach Fetch Metadata headers to requests whose URL is potentially trustworthy, meaning HTTPS or localhost. Plain-HTTP production traffic carries none. Non-browser clients such as curl, server-side webhooks, and mobile HTTP libraries never send it either, and neither do old browsers. A policy must treat a missing header as allowed, or it will break all of those callers.

Can an attacker forge Sec-Fetch-Site?

Not from a web page. The Sec- prefix makes it a forbidden request header, so JavaScript cannot set or override it, and the browser always computes it itself. An attacker with their own HTTP client can send any value, but that attacker is not a victim browser carrying the victim session cookies, which is the threat this header addresses.

Does Sec-Fetch-Site replace CSRF tokens?

Not on its own. Old browsers omit the header, so you still need an Origin check or a token as fallback, and OWASP says as much. It works well as defense-in-depth: reject state-changing requests with Sec-Fetch-Site: cross-site before they reach your auth and token logic.

Sources

  1. MDN Web Docs: Sec-Fetch-Sitedeveloper.mozilla.org
  2. W3C Fetch Metadata Request Headers, section 2.3w3c.github.io
  3. web.dev: Protect your resources from web attacks with Fetch Metadataweb.dev
  4. OWASP CSRF Prevention Cheat Sheetcheatsheetseries.owasp.org

Keep going

Browse /search