The server usually learns too little, too late.

By the time a state-changing endpoint sees a request, the browser may already have attached cookies, followed redirects, selected a request mode, and decided where the response will be used. Application code sees POST /transfer and a session cookie. It may not see the thing that made the request look strange:

this was a cross-site top-level form navigation
this was a cross-site image embed
this was a same-site request from a sibling subdomain
this was an address-bar navigation

Fetch Metadata is a small attempt to carry that missing context across the network boundary. The W3C draft defines request headers that give servers enough information to make early decisions based on how a request was made and where it will be used.1

That makes it a useful defensive layer for CSRF-shaped and cross-site data-leak attacks. It also makes it easy to overclaim. These headers are not identity, not intent, and not a replacement for tokens on unsafe actions. They are a request classifier.

Four Headers, One Classifier

Fetch Metadata exposes four browser facts:

Header Server-visible question
Sec-Fetch-Site What is the relationship between the initiator origin and the target origin?
Sec-Fetch-Mode Was this a navigate, cors, no-cors, same-origin, or websocket request?
Sec-Fetch-Dest Is the target going to be used as a document, image, script, worker, empty fetch() destination, and so on?
Sec-Fetch-User Was this navigation triggered by user activation?

The examples in the spec are the whole design in miniature. An image request can arrive with:

Sec-Fetch-Dest: image
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site

A user-clicked same-origin top-level document navigation can arrive with:

Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1

Those are not different HTTP methods. They are different browser situations. Sec-Fetch-Dest maps the empty Fetch destination to the explicit token empty; top-level navigations use document; image loads use image.2 Sec-Fetch-Mode distinguishes cors, navigate, no-cors, same-origin, and websocket.3

The sharpest header is Sec-Fetch-Site. Its values are same-origin, same-site, cross-site, and none. The setting algorithm starts at same-origin, switches to none for directly user-initiated navigation such as address-bar or bookmark navigation, and otherwise walks the request’s URL list; a cross-site URL in the redirect chain can taint the final request as cross-site.45

Sec-Fetch-User is narrower than people often remember. It is sent only for navigation requests, and only when the value is true, serialized as a Structured Field boolean such as ?1.6

The Sec- prefix matters operationally. The draft says these headers are unmodifiable from JavaScript, which prevents a malicious page from simply forging a nicer-looking request shape.7

The Gate Belongs Before The App

The natural place to use Fetch Metadata is not deep in a controller. It is a cheap precondition at the reverse proxy, CDN, edge worker, framework middleware, or first application hop:

function metadataGate(req) {
  const site = req.get("Sec-Fetch-Site");
  const mode = req.get("Sec-Fetch-Mode");
  const dest = req.get("Sec-Fetch-Dest");
  const user = req.get("Sec-Fetch-User");

  if (!site || !mode || !dest) return "block-or-route-to-legacy-exception";
  if (site === "same-origin") return "allow";
  if (site === "same-site") return "allow-but-still-check-unsafe-action-token";
  if (site === "none" && mode === "navigate" && dest === "document" && user === "?1") return "allow";
  if (site === "cross-site" && mode === "no-cors" && ["image", "style", "font", "script"].includes(dest)) return "allow-public-subresource";
  if (site === "cross-site" && mode === "navigate" && dest === "document" && user === "?1") return "allow-top-level-read";
  return "block";
}

This shape catches requests that should not have a plausible reason to exist:

cross-site + no-cors + image    -> maybe a public image
cross-site + navigate + document + user -> maybe a top-level link
cross-site + navigate + document + POST -> suspicious state-changing form
cross-site + cors + empty       -> suspicious private API fetch

The gate is strongest when it is boring. Decide which endpoints are public subresources, which endpoints are top-level documents, which endpoints are browser-facing APIs, and which endpoints are non-browser integrations with their own signatures. Then reject the shapes that do not fit.

Why This Does Not Replace CSRF Tokens

same-site is the dangerous comfort word.

Same-site is not same-origin. A request from https://promo.bank.example to https://bank.example can be same-site while still coming from a different origin. If a sibling subdomain is delegated, compromised, or simply less carefully reviewed, a metadata-only rule can let an unsafe request through.

That is why Fetch Metadata belongs next to a server-side proof for mutating actions:

metadata gate  -> does this browser request shape make sense here?
CSRF token     -> did this unsafe action come from a page we intentionally served?
signature      -> did this non-browser integration authenticate itself?
authorization  -> may this principal perform the action?

Missing headers are also a deployment decision, not a proof of attack. Old browsers, bots, command-line clients, monitoring systems, and signed webhooks may not look like browser traffic. The safe pattern is to separate browser-facing endpoints from non-browser endpoints, give the latter explicit authentication, and avoid silently treating “no metadata” as “probably fine.”

If the response varies by these headers, caching has to know. The spec calls out Vary for endpoints whose responses depend on Fetch Metadata values, for example Vary: Accept-Encoding, Sec-Fetch-Site.8

Lab: Does The Request Shape Belong Here?

The lab below is a small request-classifier model. It does not send real HTTP requests and it does not claim to reproduce every browser edge case. It isolates the decision table:

Sec-Fetch-Site relationship
request mode and destination
user-activated top-level navigation
public subresource exceptions
legacy or non-browser callers
metadata-only defense versus metadata plus token

The default scenario is a cross-site form POST to /transfer, using a metadata gate plus a CSRF token layer. The executable model gives:

Default metric Value
Metadata present yes
Would block by metadata yes
Blocked yes
Application reached no
State changed no
Audit passes

Switch Defense to report only. The same request reaches the application and changes state in the toy model, because logging a failed metadata verdict is not a defense. Switch Request to same-site POST, no token under metadata gate. The write proceeds. Then switch back to metadata + token: the request reaches application code but fails the unsafe-action proof.

The uncomfortable lesson is the useful one. Fetch Metadata blocks many bad cross-site shapes before the app spends work, but same-site writes still need an application-level proof.

Reproducing The Classifier

The simulator exports a Node-testable API:

node - <<'NODE'
const lab = require("./assets/js/fetch-metadata-lab.js");
const EXPECTED_CRITICAL_CHECKS = 13;
const EXPECTED_CASES = 27;
const EXPECTED_CHECKS = 67;
const EXPECTED_DEFAULT_HEADERS = [
  "Sec-Fetch-Site:cross-site",
  "Sec-Fetch-Mode:navigate",
  "Sec-Fetch-Dest:document",
  "Sec-Fetch-User:?1"
];
const EXPECTED_DEFAULT_SUMMARY_SHAPE = [
  "metadataPresent:1",
  "metadataPass:0",
  "wouldBlock:1",
  "blocked:1",
  "appReached:0",
  "tokenChecked:0",
  "stateChanged:0",
  "risk:0"
];
const EXPECTED_TOTALS = {
  criticalChecks: EXPECTED_CRITICAL_CHECKS,
  rows: EXPECTED_CASES,
  checked: EXPECTED_CHECKS,
  passedChecks: EXPECTED_CHECKS,
  totalChecks: EXPECTED_CHECKS
};
const EXPECTED_BY_DEFENSE_SHAPE = [
  "metadataAndToken:9/18:4/3/6/2/2/0",
  "metadataOnly:9/18:3/3/6/0/3/1",
  "reportOnly:9/18:0/3/9/0/5/3"
];
const EXPECTED_BY_REQUEST_SHAPE = [
  "crossSiteApiRead:3/6:2/3/1/0/0/0",
  "crossSiteFormPost:3/6:2/3/1/0/1/1",
  "crossSiteLink:3/6:0/0/3/0/0/0",
  "directNavigation:3/6:0/0/3/0/0/0",
  "legacyPost:3/6:2/3/1/0/1/1",
  "publicImage:3/6:0/0/3/0/0/0",
  "sameOriginPost:3/6:0/0/3/1/3/0",
  "sameSitePostNoToken:3/6:1/0/3/1/2/2",
  "signedWebhook:3/6:0/0/3/0/3/0"
];
const EXPECTED_CRITICAL_SHAPE = [
  "metadata alone allows same-site write without token:1/1",
  "metadata gate allows direct navigation:1/1",
  "metadata gate allows public image:1/1",
  "metadata gate allows user top-level link:1/1",
  "metadata gate blocks cross-site API read:1/1",
  "metadata gate blocks cross-site form POST:1/1",
  "metadata gate blocks missing headers on browser endpoint:1/1",
  "report-only cross-site form reaches cookie-only write:1/1",
  "same-origin token-bearing write succeeds:1/1",
  "signed webhook bypasses browser metadata requirement:1/1",
  "subresource omits Sec-Fetch-User:1/1",
  "token layer blocks same-site write without token:1/1",
  "user activation emits Sec-Fetch-User ?1:1/1"
];

function sameJson(actual, expected) {
  return JSON.stringify(actual) === JSON.stringify(expected);
}

function summaryShape(summary) {
  return EXPECTED_DEFAULT_SUMMARY_SHAPE.map((entry) => {
    const key = entry.split(":")[0];
    return `${key}:${summary[key]}`;
  });
}

const defaultHeaders = lab.headersFor(lab.requests.crossSiteFormPost);
const defaultSummary = lab.evaluate({}).summary;
console.log(defaultHeaders);
console.log(defaultSummary);
const audit = lab.auditFetchMetadataLab();
console.table(audit.byDefense);
console.table(audit.byRequest);
console.table(audit.criticalChecks);
const auditShape = {
  defaultHeaders: defaultHeaders.map(([name, value]) => `${name}:${value}`),
  defaultSummary: summaryShape(defaultSummary),
  totals: {
    criticalChecks: audit.criticalTotal,
    rows: audit.cases.length,
    checked: audit.checked,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  },
  byDefense: audit.byDefense.map((row) => (
    `${row.defense}:${row.cases}/${row.totalChecks}:` +
    `${row.blocked}/${row.metadataWouldBlock}/${row.appReached}/` +
    `${row.tokenChecked}/${row.stateChanged}/${row.risk}`
  )),
  byRequest: audit.byRequest.map((row) => (
    `${row.request}:${row.cases}/${row.totalChecks}:` +
    `${row.blocked}/${row.metadataWouldBlock}/${row.appReached}/` +
    `${row.tokenChecked}/${row.stateChanged}/${row.risk}`
  )),
  criticalChecks: audit.criticalChecks.map(
    (row) => `${row.check}:${row.passedChecks}/${row.totalChecks}`
  ).sort()
};
const shapeErrors = [
  sameJson(auditShape.defaultHeaders, EXPECTED_DEFAULT_HEADERS) ? null : "defaultHeaders",
  sameJson(auditShape.defaultSummary, EXPECTED_DEFAULT_SUMMARY_SHAPE) ? null : "defaultSummary",
  sameJson(auditShape.totals, EXPECTED_TOTALS) ? null : "totals",
  sameJson(auditShape.byDefense, EXPECTED_BY_DEFENSE_SHAPE) ? null : "byDefense",
  sameJson(auditShape.byRequest, EXPECTED_BY_REQUEST_SHAPE) ? null : "byRequest",
  sameJson(auditShape.criticalChecks, EXPECTED_CRITICAL_SHAPE) ? null : "criticalChecks"
].filter(Boolean);
if (shapeErrors.length) {
  throw new Error(
    `audit grid drifted in ${shapeErrors.join(", ")}:\n` +
    JSON.stringify(auditShape, null, 2)
  );
}
const failedCriticalChecks = audit.criticalChecks.filter(
  (row) => !row.passed || row.passedChecks !== row.totalChecks
);
const failedCases = audit.cases.filter(
  (row) => !row.passed || row.passedChecks !== row.totalChecks
);
if (!audit.ok || failedCriticalChecks.length || failedCases.length || audit.passedChecks !== audit.totalChecks) {
  throw new Error(JSON.stringify({
    errors: audit.errors,
    failedCriticalChecks,
    failedCases,
    shapeErrors,
    auditShape
  }, null, 2));
}
console.log(`${audit.passedChecks}/${audit.totalChecks} checks passed`);
NODE

Default headers:

[
  ["Sec-Fetch-Site", "cross-site"],
  ["Sec-Fetch-Mode", "navigate"],
  ["Sec-Fetch-Dest", "document"],
  ["Sec-Fetch-User", "?1"]
]

Default summary:

{
  "metadataPresent": 1,
  "metadataPass": 0,
  "wouldBlock": 1,
  "blocked": 1,
  "appReached": 0,
  "tokenChecked": 0,
  "stateChanged": 0,
  "risk": 0
}

The audit currently runs 13 named critical checks plus 54 generated invariant checks over a 27-row request/defense grid (67 checks total). Among the invariants:

  • user-activated navigations emit Sec-Fetch-User: ?1;
  • subresource requests do not emit Sec-Fetch-User;
  • cross-site form posts and cross-site private API reads are blocked by the metadata gate;
  • public cross-site image embeds and direct user navigations are allowed;
  • metadata alone allows a same-site write without a token;
  • metadata plus token blocks that same-site write;
  • signed webhooks can use an application signature instead of browser metadata;
  • no scenario can both be blocked and mutate state.

What I Would Put In A Review

The review question is not “do we use Fetch Metadata?” It is:

does every browser-facing endpoint have a small set of request shapes that make sense?

My checklist would be:

Question Evidence
Which paths are public subresources? Cross-site no-cors embeds are allowed only for explicit static assets or public resources.
Which paths are top-level documents? Cross-site user-activated document GET navigations are allowed where deep links are intended.
Which paths are private APIs? Cross-site cors or no-cors empty-destination requests do not reach private API handlers by default.
Which paths mutate state? Unsafe actions still require a CSRF token, same-origin nonce, or equivalent server proof.
Which clients are not browsers? Webhooks, CLI clients, health checks, and monitoring paths use signatures or separate auth instead of metadata guesses.
Which responses depend on metadata? Cacheable variants include the relevant Vary header.

Fetch Metadata is best when it is unglamorous plumbing. It gives the server an early, browser-supplied sentence about the request:

who caused me, how am I being fetched, and what will I become?

That sentence is not enough to trust the request. It is often enough to know the request does not belong.