Your Browser Labels Every Request. Use It.

Browsers stamp every request with unforgeable Sec-Fetch headers. One middleware turns them into a CSRF firewall before your tokens run.

Share

Every request your app receives from a browser carries a small set of headers describing exactly where it came from and how it was triggered. Almost nobody reads them. They're called Fetch Metadata — Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest — and they're the cheapest CSRF defense you're not using.

The browser sets them on every request, JavaScript can't forge them (they're forbidden header names), and non-browser clients like curl or your mobile app simply don't send them. That's a free trust signal arriving on every hit, and your framework ignores it.

Why this matters

CSRF still shows up in breach reports because token checks require plumbing into every form and fetch call, and one forgotten endpoint becomes an incident. Fetch Metadata gives you a coarse but reliable gate you can bolt on in an afternoon — in your app, or in a Worker or nginx rule in front of it.

It won't replace tokens. It rejects the bulk of cross-site attacks before your application code even runs, which buys you time to fix the endpoints you haven't audited yet.

How it works

The browser annotates each request with three useful facts:

  • Sec-Fetch-Site: same-origin, same-site, cross-site, or none (typed or bookmarked URL)
  • Sec-Fetch-Mode: navigate for top-level navigation, cors or no-cors for fetch and friends
  • Sec-Fetch-Dest: what the response is for — document, script, image, empty

The whole policy fits in one sentence: if the request is cross-site, allow it only when it's a plain GET top-level navigation. Everything else cross-site gets a 403. An attacker page auto-submitting a form to your /settings endpoint arrives as cross-site + navigate + POST. Dead before your route handler starts.

Where this helps

  • Legacy monoliths: endpoints older than your CSRF middleware get blanket coverage while you retrofit tokens.
  • Edge filtering: a Cloudflare Worker or nginx location block drops cross-site POSTs before they consume origin capacity.
  • Sensitive flows: login, logout, and email-change — the classic CSRF targets — get a hard first gate.
  • SPAs: your own frontend is same-origin and passes through untouched.

Watch out

  • Missing headers mean an older browser or a non-browser client. Treat absence as unknown and fall through to your token check — never reject on absence, or you'll break APIs and mobile apps.
  • Same-site passes: subdomains share a site, so user content on a sibling subdomain sails through this gate.
  • GET is allowed by design: endpoints with side effects on GET, like logout links, stay CSRF-able until you make them POST.
  • Legit cross-site calls — embeds, partner integrations — need explicit route exemptions.

Try it yourself

Drop this Express middleware in and simulate an attacker:

function fetchShield(req, res, next) {
  const site = req.headers['sec-fetch-site'];
  const mode = req.headers['sec-fetch-mode'];
  const dest = req.headers['sec-fetch-dest'];

  // Not a modern browser: fall through to your token check
  if (site === undefined) return next();

  // Typed URLs, bookmarks, same-site, same-origin: fine
  if (site !== 'cross-site') return next();

  // Cross-site is only OK for plain top-level GET navigation
  const topLevelGet =
    mode === 'navigate' && dest === 'document' && req.method === 'GET';
  if (topLevelGet) return next();

  res.status(403).json({ error: 'cross-site request rejected' });
}

app.use(fetchShield);

Then prove it: curl -X POST localhost:3000/api/settings -H 'Sec-Fetch-Site: cross-site' -H 'Sec-Fetch-Mode: cors' returns 403 before any route logic executes.

TL;DR

  • Browsers stamp every request with Sec-Fetch-* headers that JavaScript cannot forge.
  • Rejecting cross-site non-navigation requests blocks CSRF before your token check even runs.
  • Add the middleware today, keep tokens for clients that don't send the headers.