Fetch Metadata: Use Sec-Fetch-Site to Reject Requests Your App Never Wanted
A practical guide to Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest: building a resource isolation policy without breaking navigation, APIs, webhooks or older clients.
A lot of web security advice starts with the request body, a CSRF token or an Origin header. Fetch Metadata gives the server something useful slightly earlier: context about how the browser made the request.
That sounds minor until you look at a request to an authenticated endpoint and ask a blunt question:
Why should this endpoint accept a cross-site request at all?
For many application routes, it should not.
Modern browsers send Sec-Fetch-* request headers that describe the relationship between the page initiating a request and the server receiving it, the request mode, its destination, and in some navigation cases whether there was user activation. Because these are Sec- headers, page JavaScript cannot simply forge them.
That makes them useful for a resource isolation policy: reject categories of browser requests that make no sense for the resource before the application gets deep into processing them.
This is defence in depth, not a replacement for CSRF protection, authentication or CORS. Used that way, it is a surprisingly clean extra boundary.
Sec-Fetch-Site is the header I would understand first
For most application-level policies, Sec-Fetch-Site does the most immediately useful work.
A browser can send:
Sec-Fetch-Site: same-origin
Sec-Fetch-Site: same-site
Sec-Fetch-Site: cross-site
Sec-Fetch-Site: none
The distinctions matter.
same-origin means the initiator has the same scheme, host and port as the target.
same-site is broader. Two origins can be different while still belonging to the same site. If you run several subdomains, do not casually treat same-site as equivalent to same-origin unless you are comfortable trusting those sibling origins.
cross-site is the interesting case for isolation. The request originated from a different site.
none is not an error or an unknown origin. It is used for directly user-initiated requests without an initiating site, such as typing a URL into the address bar or opening a bookmark.
So a policy that blindly accepts only same-origin can break perfectly legitimate navigation.
The browser gives you more than provenance
The other core Fetch Metadata headers answer different questions.
Sec-Fetch-Mode describes the request mode. Common values include navigate, cors, no-cors, same-origin and websocket.
Sec-Fetch-Dest describes how the response is intended to be used. You will see values such as document, image, script, style, font, iframe, worker, or empty. Requests made with fetch() commonly have an empty destination.
Sec-Fetch-User can appear on navigation requests activated by the user and, when present for activation, has the value ?1.
Put together, the headers let a server distinguish requests that would otherwise look superficially similar.
A normal same-origin API request might arrive as:
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
A third-party page trying to load one of your endpoints as an image can look more like:
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image
And somebody following a link to your site can legitimately produce:
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Sec-Fetch-User: ?1
Those are very different intentions. Treating all cross-site traffic as hostile would throw away useful context the browser has already supplied.
A useful policy is about impossible requests
The tempting implementation is:
if Sec-Fetch-Site == cross-site:
return 403
I would not deploy that site-wide.
Your public pages are supposed to receive cross-site navigations. OAuth callbacks may cross site boundaries. Payment providers return users to applications. Webhooks are often server-to-server and may not contain Fetch Metadata headers at all. Public APIs can intentionally support cross-origin callers.
The better question is route-specific:
Which request contexts are impossible or unwanted for this endpoint?
An authenticated /account/change-email endpoint probably has no useful reason to accept a cross-site subresource request.
Your homepage absolutely has a reason to accept a cross-site top-level navigation: that is how links work.
A webhook endpoint should generally authenticate the webhook according to the provider’s signing mechanism, not assume browser metadata will exist.
That difference is the whole implementation.
A conservative resource isolation policy
MDN describes a common pattern: allow same-origin requests, allow directly initiated requests, permit safe top-level navigation, and reject other cross-site requests unless an endpoint is deliberately shared.
In pseudocode, I would start closer to this:
site = Sec-Fetch-Site
mode = Sec-Fetch-Mode
if site is missing:
fall back to existing security controls
if site == same-origin:
allow
if site == none:
allow
if mode == navigate and method in [GET, HEAD]:
allow
if endpoint is explicitly cross-site:
apply that endpoint's normal CORS/auth/signature rules
otherwise:
reject
Notice the missing-header branch.
That is deliberate.
A new defence should not turn every old browser, monitoring probe, mobile client, CLI, webhook sender or non-browser integration into a production incident. Start by using Fetch Metadata where it exists and preserve your existing controls where it does not.
Once you know your client estate, you can decide whether particular endpoints can safely be stricter.
Do not make same-site a free pass without thinking about subdomains
This is one of the places where apparently sensible sample code can become too generous.
Suppose you operate:
https://app.example.com
https://marketing.example.com
https://uploads.example.com
Requests between those origins can be same-site while not being same-origin.
If uploads.example.com hosts user-controlled material, or a legacy subdomain has a weaker security posture, a blanket rule saying “same-site is trusted” increases the authority of that origin.
For sensitive application endpoints I prefer to start with same-origin as the obvious trust boundary, then deliberately allow the cross-origin or same-site flows the product actually requires.
The browser’s classification is useful information. It is not a substitute for deciding which of your origins you trust.
Fetch Metadata complements CSRF tokens rather than replacing them
It is easy to see Sec-Fetch-Site: cross-site rejection and conclude that CSRF tokens have become unnecessary.
I would not make that leap.
CSRF defences protect an application according to its authentication and state-changing semantics. Fetch Metadata gives you another signal about browser request context. The two mechanisms fail differently and cover different client populations.
Keeping a proper anti-CSRF mechanism also means your security does not collapse because a request arrives from a client that does not send Fetch Metadata.
Think of resource isolation as reducing the set of requests that reach the sensitive part of the application, not as deleting the controls inside it.
The same principle applies to Content Security Policy. CSP constrains what a document may load and execute; Fetch Metadata lets the receiving server reason about the context of an incoming browser request. They operate at different boundaries.
It is not CORS either
CORS is frequently misunderstood as a server-side request firewall.
It is primarily a browser mechanism controlling whether frontend JavaScript is allowed to read certain cross-origin responses. A request can reach your server even when browser CORS rules prevent the initiating script from reading the response.
A Fetch Metadata policy can make an earlier server-side decision to reject a request because its context is inappropriate.
That does not mean every cross-site CORS request should be rejected. If you intentionally expose an API to another origin, that route belongs on the explicit allow path.
This is why I would implement isolation near routing or middleware, with visible exceptions, rather than hide it in a generic CDN rule nobody remembers exists.
Sec-Fetch-Dest catches requests that should never make sense
Destination is particularly useful when a sensitive resource has a narrow purpose.
If /api/account is an API endpoint, a request claiming a destination of image is not a legitimate way your frontend consumes it.
Likewise, an HTML admin document is not expected to be requested as a script.
I would resist building an enormous destination matrix for the entire site. That becomes brittle. But for especially sensitive routes, destination can turn a broad origin rule into something more precise.
For example:
/admin/*
allow same-origin
allow direct/top-level document navigation where intended
reject cross-site subresource use
The value is not that Sec-Fetch-Dest proves a user is authorised. It does not. The value is that it can identify a request shape the application never intended to support.
Be careful with caches
If your response changes depending on Fetch Metadata headers and that response can be cached, the cache needs to understand the variation.
MDN’s resource-isolation example sets a Vary header for the metadata values used by its decision:
Vary: Sec-Fetch-Site, Sec-Fetch-Mode
Without appropriate cache separation, a response generated for one request context can potentially be reused for another.
Do not add Vary mechanically to every dynamic response, either. Vary changes cache keys and can reduce cache efficiency. The correct answer depends on whether the resource is cacheable and whether the metadata affects the representation or allow/deny decision at the caching layer.
This is exactly the kind of detail that gets lost when Fetch Metadata is presented as a three-line security-header trick.
ASP.NET Core middleware
For a .NET application, I would keep the first version boring and explicit.
app.Use(async (context, next) =>
{
var site = context.Request.Headers["Sec-Fetch-Site"].ToString();
var mode = context.Request.Headers["Sec-Fetch-Mode"].ToString();
// Non-browser/legacy clients: preserve existing auth, CSRF and endpoint controls.
if (string.IsNullOrEmpty(site))
{
await next();
return;
}
if (site is "same-origin" or "none")
{
await next();
return;
}
var safeNavigation =
mode == "navigate" &&
(HttpMethods.IsGet(context.Request.Method) ||
HttpMethods.IsHead(context.Request.Method));
if (safeNavigation)
{
await next();
return;
}
// Put explicit cross-site integrations before this rejection in a real app.
context.Response.StatusCode = StatusCodes.Status403Forbidden;
});
I would not paste this globally into an existing SaaS and deploy it on Friday afternoon. OAuth, SSO, payment returns, embedded integrations and public API routes need to be mapped first.
A better first deployment is often limited to a sensitive route group where the expected request contexts are already clear.
Express middleware
The same policy is straightforward in Node:
function fetchMetadataIsolation(req, res, next) {
const site = req.get('Sec-Fetch-Site');
const mode = req.get('Sec-Fetch-Mode');
if (!site) return next();
if (site === 'same-origin' || site === 'none') return next();
if (mode === 'navigate' && (req.method === 'GET' || req.method === 'HEAD')) {
return next();
}
return res.sendStatus(403);
}
Again, exceptions are part of the design, not an embarrassment to hide. If /webhooks/stripe or /api/public/* has a different security contract, keep it outside this generic browser isolation policy and secure it according to that contract.
Log before you block
For an established application, the most useful rollout starts in observation mode.
Record, for candidate protected routes:
- method and route template;
Sec-Fetch-Site;Sec-Fetch-Mode;Sec-Fetch-Dest;- whether the request would have been rejected;
- client class where you can identify it without logging sensitive data.
Do not dump authentication tokens, query-string secrets or entire request bodies into a security log just because you are adding a new policy.
Run that long enough to catch real workflows: login, logout, SSO, password reset, payments, embedded tools, browser extensions, monitoring and mobile clients.
Then turn rejection on for routes where the policy is unambiguous.
I would much rather ship a narrow policy that I can explain than a global rule followed by twenty emergency exceptions.
Test with real browser requests, not hand-written headers alone
You can use curl to exercise your server’s branches, but remember that the useful property of Sec-Fetch-* in browsers is that application JavaScript cannot set those forbidden request headers arbitrarily.
So test both layers:
Use integration tests to prove your middleware makes the intended decisions for each header combination.
Then use supported browsers to prove actual navigation, fetch, iframe and subresource flows produce the contexts you expected.
MDN currently marks Sec-Fetch-Site and Sec-Fetch-Mode as widely available, with cross-browser availability dating from March 2023. Sec-Fetch-User has a less uniform compatibility story, which is another reason I would not make one optional header the foundation of the whole policy.
The routes I would look at first
Fetch Metadata is most attractive where an endpoint is browser-facing, authenticated, sensitive, and has a very predictable legitimate request shape.
Account settings, administrative actions and internal JSON APIs are obvious candidates.
Public documents, intentionally embeddable resources, federated login endpoints, payment flows, webhook receivers and third-party APIs deserve separate thought.
There is no prize for blocking the largest percentage of traffic. The win is making an attacker cross another boundary without making legitimate integrations mysterious.
If you already review response-side protections with SiteTidy’s website audit, Fetch Metadata is a useful reminder that not every browser security control is a response header you can detect with a scanner. Some of the best hardening lives in what your server decides to do with the request context it receives.
A rollout I would actually use
I would implement this in four stages.
First, inventory sensitive browser endpoints and their legitimate cross-site flows. Do this before writing a global rule.
Second, log Fetch Metadata on those routes and calculate a would-block decision without enforcing it.
Third, enforce a conservative policy on a small route group: same-origin and direct requests accepted, safe navigations accepted where appropriate, missing headers falling back to existing controls, known integrations explicitly handled.
Finally, expand only when the logs and tests show that another route has an equally clear contract.
That approach is less exciting than adding a security middleware package and turning every option on. It is also far less likely to break the login flow you only discover after deployment.
Fetch Metadata works best when you treat it as information about intent, not proof of trust. Authentication still decides who the caller is. CSRF protection still guards state-changing browser flows. CORS still governs cross-origin frontend access. CSP still constrains what your pages load.
Sec-Fetch-* gives the server one more useful question to ask before doing expensive or sensitive work:
Does a request made this way belong here at all?
