Sec-Fetch-Site: Block Cross-Site POSTs Early

Sec-Fetch-Site tells your server how the request’s initiator relates to your origin. It carries one of four values: same-origin, same-site, cross-site, or none for something the user started in the browser itself, like a typed URL. Reject unsafe methods when the value is cross-site and a forged form POST from another site dies before your handler runs.

The reason this works is the header name. MDN classifies Sec-Fetch-Site as a forbidden request header, so script on a page cannot set it or change it. The W3C Fetch Metadata specification words the same section differently, calling the Sec- prefixed names “forbidden response-header names, and therefore unmodifiable from JavaScript”, which is what stops “malicious websites from convincing user agents to send forged metadata along with requests”. Either way the browser owns the value, and that is the property the whole defence rests on. OWASP’s CSRF prevention cheat sheet calls Sec-Fetch-Site “a lightweight and reliable method to block obvious cross-site requests”, and names it “the primary signal for CSRF protection”.

What does the browser actually send?

I put three local origins behind a Node server that logged every Sec-Fetch-* header, then drove Chrome 152 through nine request shapes. The interesting ones:

# address bar or bookmark
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Sec-Fetch-User: ?1

# link clicked on another site
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Sec-Fetch-User: ?1

# form POST auto-submitted by script on another site
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Origin: http://localhost:8787

# <img> on another site pointing at one of your endpoints
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image

# <iframe> on another site pointing at one of your pages
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: iframe

Two things fall out of that. The forged POST and the legitimate inbound link carry the same Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest, so the method is what separates them, and the method is the whole basis of the defence. And framing produces Sec-Fetch-Dest: iframe while a real page load produces document, which turns out to be the gap in the policy everyone copies.

One more detail: requests to different ports on the same host came back as same-site, not same-origin. Site is computed from scheme and registrable domain, and ports are not part of it.

How do you use Sec-Fetch-Site for CSRF protection?

Run the check as middleware, before authentication. web.dev’s original write-up on the pattern is Protect your resources from web attacks with Fetch Metadata, and it is blunt about the ordering. “Make sure that you reject invalid requests before running authentication checks or any other processing of the request to prevent revealing sensitive timing information.”

type Decision = { allow: true } | { allow: false; reason: string };

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

// Endpoints meant to be called cross-origin. Secure these with CORS, a
// signature check or auth; the policy steps aside for them.
const CROSS_ORIGIN_ENDPOINTS = new Set(['/api/webhooks/stripe']);

const TRUSTED_ORIGINS = new Set(['https://app.example.com']);

export function checkRequest(req: {
  method: string;
  path: string;
  headers: Record<string, string | undefined>;
}): Decision {
  if (CROSS_ORIGIN_ENDPOINTS.has(req.path)) return { allow: true };

  const site = req.headers['sec-fetch-site'];

  // Absent: an old browser, a non-browser client, or an intermediary that
  // stripped it. Fall back to the Origin check rather than guessing.
  if (site === undefined) return checkOrigin(req);

  if (site === 'same-origin' || site === 'none') return { allow: true };

  // Sibling subdomains are a different origin. Reads only, unless you
  // control every host under the registrable domain.
  if (site === 'same-site') {
    if (SAFE_METHODS.has(req.method)) return { allow: true };
    return checkOrigin(req);
  }

  if (site === 'cross-site') {
    // Inbound links have to keep working: a top-level GET whose destination
    // is the document itself, not an iframe, object or embed.
    if (
      SAFE_METHODS.has(req.method) &&
      req.headers['sec-fetch-mode'] === 'navigate' &&
      req.headers['sec-fetch-dest'] === 'document'
    ) {
      return { allow: true };
    }
    return { allow: false, reason: `cross-site ${req.method} to ${req.path}` };
  }

  // The spec tells servers to ignore values they do not recognise, so a
  // future token must not fail open on its own.
  return checkOrigin(req);
}

function checkOrigin(req: {
  method: string;
  path: string;
  headers: Record<string, string | undefined>;
}): Decision {
  if (SAFE_METHODS.has(req.method)) return { allow: true };

  const origin = req.headers['origin'];
  if (origin === undefined) {
    return { allow: false, reason: 'no Sec-Fetch-Site and no Origin' };
  }
  if (TRUSTED_ORIGINS.has(origin)) return { allow: true };
  return { allow: false, reason: `untrusted Origin ${origin}` };
}

The cross-site GET exception is what keeps your site linkable, and it has a consequence you have to accept deliberately. A cross-site top-level GET is allowed, so any endpoint that changes state on a GET is still forgeable. OWASP puts it plainly: “Safe HTTP methods should not be used for state-changing requests.”

The same-site branch is the one people get wrong. A subdomain you do not control reports same-site. So does a marketing site on a shared registrable domain, and a legacy app someone else deploys. OWASP says to treat the value as allowed “only if your threat model trusts sibling subdomains”.

Why the standard policy still allows cross-site iframes

The rule everyone copies comes from web.dev’s Resource Isolation Policy, and its third step is titled “Allow simple top-level navigation and iframing”. The code excludes two destinations, with the comment “<object> and <embed> send navigation requests, which we disallow”. OWASP reproduces the same check. Neither version blocks a cross-site <iframe> of your pages, and web.dev’s heading says that is deliberate.

My measurement above shows why: framing arrives as Sec-Fetch-Mode: navigate with Sec-Fetch-Dest: iframe, which passes a check that only excludes object and embed. The version above requires document instead, which closes framing too. That is stricter than the published rule, so decide before you ship it: if anything legitimately embeds your pages, requiring document breaks it, and Content-Security-Policy: frame-ancestors is the control actually designed for that job.

Sec-Fetch-User is not in any version of Safari

The cheat sheet suggests refining a policy with Sec-Fetch-Mode, Sec-Fetch-Dest and Sec-Fetch-User, and states that “Sec-Fetch-* is supported in all major browsers since March 2023”. For Sec-Fetch-User that is not what the compatibility data says. MDN marks Sec-Fetch-User as “Limited availability”, and the underlying browser-compat-data entry records version_added: false for Safari, which Safari on iOS mirrors. It points at WebKit bug 247697, “Add support for Sec-Fetch-User”, whose status is still NEW. That data used to claim Safari 16.4, and was corrected after an issue argued the claim “does not appear to be the case”.

Follow the compatibility data. Sec-Fetch-Site and Sec-Fetch-Mode shipped in Chrome 76, Firefox 90 and Safari 16.4, and Sec-Fetch-Dest in Chrome 80 with the same Firefox and Safari versions. MDN records all three as available across browsers since March 2023. Sec-Fetch-User has not. A rule that requires Sec-Fetch-User: ?1 before accepting a POST rejects every Safari user you have.

The loss stings, because that header separates the two cases you most want separated. In my run, a cross-site form POST auto-submitted by script carried no Sec-Fetch-User at all, while the same form submitted by a human click carried ?1. Chrome tells you whether a user gesture started the navigation. Safari does not.

What breaks a Fetch Metadata policy in production?

Header stripping, mostly. Proxies, gateways and load balancers “may remove or modify Origin and Sec-* headers”, and OWASP calls that “problematic, but common”. This is the failure that actually hurts, because it turns a working policy into the undefined branch of the code above without anyone noticing, and the same sentence in the cheat sheet covers Origin, which is the fallback that branch depends on. Before you enforce anything, log what reaches your application. Not what the browser sent.

Then three smaller ones. The headers only go to potentially trustworthy URLs, which OWASP lists as “https, wss, file, and localhost (including 127.0.0.0/8 and ::1/128)”, so plain HTTP gets nothing and HSTS belongs alongside the policy. Prerender and prefetch “may send Sec-Fetch-* values that don’t match the final navigation”, so header propagation “isn’t fully stable across all navigation types”. And if a response varies by these headers, caches need Vary: Sec-Fetch-Site, Origin, though OWASP is careful that this is “operational rather than defensive” and “does not impact CSRF defenses in any way”.

What I would actually deploy

Add the check in log-only mode first, which is what web.dev recommends, and leave it there a fortnight before enforcing anything. That is how you find the internal tool or legacy integration that quietly posts cross-site. Then enforce, with the absent-header fallback failing closed on your account and payment routes and open elsewhere. Keep your CSRF tokens on those same routes. OWASP calls origin verification a fallback, and says of SameSite that it is “a defense-in-depth control” which “does not replace a proper CSRF defense in most deployments”.

I would skip all of it on a pure JSON API that already requires a custom header and a CORS preflight, because the preflight is doing this job already. Everywhere else the cost is a few dozen lines, no client changes, and a check that runs before anything expensive. If you are hardening headers anyway, it sits alongside our write-up on CSP nonces and hashes, and the webhook exemption in the code above is exactly where signature verification on inbound webhooks has to take over.

Whoooop Ltd does this kind of hardening on Node and TypeScript applications, usually as part of a broader website security review. If you want the policy fitted, measured in log-only mode and then enforced without breaking your integrations, that is the work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch