DEV Community

Pop Watch
Pop Watch

Posted on

Fetch Metadata: A Practical Cross-Site Request Policy for Node.js

Most web security controls answer a narrow question. CORS decides whether browser JavaScript may read a cross-origin response. CSRF tokens bind a state-changing request to an application session. Content Security Policy constrains what a page can load.

Fetch Metadata answers a useful question earlier: what kind of browser context caused this request? It gives a server request headers such as Sec-Fetch-Site, Sec-Fetch-Mode, and Sec-Fetch-Dest. That is enough context to reject many implausible cross-site requests before they reach application code.

This is not a replacement for authentication, authorization, CSRF defenses, or input validation. It is a small, server-side policy layer that makes unsafe request paths less reachable.

The signal a server actually receives

The Fetch Metadata specification defines four request headers. The most useful starting point is Sec-Fetch-Site:

Value Meaning
same-origin Initiator and target have the same origin
same-site They are different origins within the same site
cross-site The initiator is on another site
none A direct user-agent navigation, such as the address bar or a bookmark

The browser sets these headers for trustworthy requests. The Sec- prefix matters: JavaScript cannot set or modify them, so a malicious page cannot simply forge Sec-Fetch-Site: same-origin with fetch().

Two companion headers add context:

  • Sec-Fetch-Mode distinguishes navigate, cors, no-cors, same-origin, and websocket.
  • Sec-Fetch-Dest says whether the target is a document, image, script, iframe, or the empty destination used by fetch().

For example, an image embedded by another site commonly arrives as cross-site / no-cors / image. That is very different from a same-origin API call. The specification also defines Sec-Fetch-User: ?1 for user-activated navigations, but it is only present for such navigations; do not use its absence as proof that a request is automated or hostile.

Why this is not CORS

A common mistake is treating CORS as a request firewall. It is not. CORS governs whether browser code can read a response. A cross-site form submission or image request can still reach your server, and ambient credentials may still accompany a request depending on cookie policy and context.

Fetch Metadata lets the server decide whether to service the request at all. A policy can reject a cross-site request to a private JSON endpoint even if the caller would never be allowed to read the response. That reduces exposure to cross-site probing and cuts unnecessary work.

CORS still has a job: explicitly define which origins may read API responses. CSRF protections still have a job: protect cookie-authenticated state changes, including clients that do not send Fetch Metadata. Keep both.

Start with a conservative Node.js policy

The following example is framework-neutral middleware for a Node http server. It blocks cross-site requests by default, while preserving a narrowly scoped public image route. Missing headers are allowed initially for compatibility: older clients, non-browser clients, and some embedded environments may not send them.

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

  // Compatibility first: observe clients without this signal.
  if (!site) return next();

  const isCrossSite = site === 'cross-site';
  const isPublicImage =
    req.url.startsWith('/public-images/') &&
    req.method === 'GET' &&
    mode === 'no-cors' &&
    dest === 'image';

  if (isCrossSite && !isPublicImage) {
    res.writeHead(403, {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-store',
      'Vary': 'Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest',
    });
    res.end('Cross-site request rejected by policy');
    return;
  }

  next();
}
Enter fullscreen mode Exit fullscreen mode

Place this before routes that parse bodies, query databases, or invoke expensive downstream services. At a CDN or reverse proxy, the same decision can be even cheaper—but only after you have verified that the proxy forwards these request headers and that the policy is identical across origins.

The example deliberately does not say “block every non-same-origin request.” Many real applications legitimately need same-site subdomains, OAuth callbacks, payment-provider returns, webhooks, mobile clients, or public assets. A simplistic allowlist can break them silently.

Deploy in two phases

A good policy is discovered from traffic, not guessed from a diagram.

  1. Classify endpoints. Mark routes as private APIs, state-changing actions, navigations, public assets, webhooks, or intentionally embeddable resources. Only private and state-changing routes should begin with a restrictive default.
  2. Observe first. Log the method, route class, and the three Sec-Fetch-* values. Avoid logging cookies, authorization headers, request bodies, or query values. The point is to learn shapes, not collect user data.
  3. Add explicit exceptions. For each expected cross-site path, record why it exists, its allowed method and destination, and an owner. “It fixed a 403 once” is not an exception rationale.
  4. Enforce on a small route group. Return a clear 403, monitor failures, then expand coverage. Treat a missing header separately from a hostile value until your client inventory supports a stricter mode.

The specification notes that redirects are significant: once a redirect chain crosses sites, Sec-Fetch-Site can remain cross-site even if it later returns to your domain. Test login and payment return flows rather than assuming their final URL tells the whole story.

Cache and browser-extension boundaries

If a response changes based on Fetch Metadata, caches must not confuse variants. The specification calls out using Vary for the relevant request header, for example Vary: Sec-Fetch-Site. For a denial response, Cache-Control: no-store avoids accidental reuse. Do not add Vary mechanically to every response: it can fragment caches. Add it exactly where representation or status differs by the header.

Browser extensions are another boundary worth testing. Extensions may have permissions that change how requests are represented. Do not broadly exempt extension traffic because it is inconvenient; define a separate authenticated integration path if your product needs one.

What Fetch Metadata cannot prove

These headers describe browser request context, not identity or intent. They cannot authorize a user, validate a webhook signature, protect a non-browser client, or make an unsafe endpoint safe. They also do not defend against a malicious script running on your own origin.

Use Fetch Metadata as defense in depth alongside server-side authorization, CSRF tokens or origin checks where appropriate, secure cookie attributes, rate limits, and careful response design. Its real value is architectural: it gives infrastructure a cheap, standardized signal for rejecting requests that should never have reached the application.

Sources

Top comments (0)