You're viewing the readable version of this site. The interactive extras (search, diagrams, read-aloud) need JavaScript and a current browser. Enable JavaScript; if it is already enabled, update your browser.

Notes · Dissecting Real Systems

growing

Where Did This Request Come From?

The Sec-Fetch-Site, -Mode, -Dest and -User headers answer a question servers could never ask — and turn a class of cross-site attacks into a four-line check.

· · 31 min read

security, csp, http, web, browsers, csrf, cookies, hsts, dissecting-systems

One of the most important properties of a program is whether or not it carries out its intended function.

C. A. R. Hoare, An Axiomatic Basis for Computer Programming (1969)

Cite this
APA
Mangalapilly, Y. J. (2026, July). Where Did This Request Come From?. Saṃhitā Notes. https://yesudeep.com/blog/where-did-this-request-come-from/
BibTeX
@online{mangalapilly2026where,
  author  = {Yesudeep Jose Mangalapilly},
  title   = {Where Did This Request Come From?},
  journal = {Sa\d{m}hit\=a Notes},
  year    = {2026},
  month   = {July},
  url     = {https://yesudeep.com/blog/where-did-this-request-come-from/},
  urldate = {2026-08-12},
}
Plain
Yesudeep Jose Mangalapilly. “Where Did This Request Come From?.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/where-did-this-request-come-from/.
RIS
TY  - ELEC
AU  - Mangalapilly, Yesudeep Jose
TI  - Where Did This Request Come From?
T2  - Saṃhitā Notes
PY  - 2026
UR  - https://yesudeep.com/blog/where-did-this-request-come-from/
Y2  - 2026-08-12
ER  - 

A server-side boundary that sits beside the one the rest of this series is about. By the end you'll know what the four fetch-metadata headers say, why the Sec- prefix is what makes them trustworthy, how the Resource Isolation Policy turns them into a short rejection rule, which attack classes that rule eliminates and which it leaves untouched, and why this is a different boundary from a Content Security Policy rather than a competing one — CSP governs what may execute in a document, fetch metadata governs whether the response should exist at all.

A cross-site request forgery works because the browser is helpful. When a page on evil.example causes a request to bank.example — by submitting a form, setting an <img src>, or firing a fetch — the browser attaches bank.example's cookies, because those cookies belong to the destination, not the initiator. The request arrives at the bank looking exactly like one the user made themselves.

That last sentence is the whole problem, and it is worth being precise about why it is true. The server is not careless. Before browsers shipped Fetch Metadata, the transmitted request had no field that distinguished the two cases. The method matched, the path matched, the cookies matched, the Host matched. The one fact that mattered — a document on another site caused this — was known to the browser and never spoken aloud.

So the question in this article's title is one a server genuinely could not answer. Not for lack of trying: it is the question every CSRF defense of the last two decades has been an attempt to answer indirectly, using evidence the request never carried.

Cross-site request forgery (CSRF) is an attack that causes a victim's browser to issue a state-changing request to a site where the victim is authenticated, using the victim's ambient credentials. The victim need only visit the attacker's page. Learn more.

Every classical CSRF defense is a workaround for a missing header: an attempt to reconstruct, in application code, provenance the browser knew all along.

Look at the standard defenses in that light and they rhyme. A synchronizer token embeds an unguessable value in the form so that only a document the server generated can produce a valid submission — proving initiator identity by proving possession of a secret. Double-submit cookies compare a cookie against a request field, exploiting the fact that a cross-site attacker can cause the cookie to be sent but cannot read it. Checking the Origin header comes closest to asking the real question, but Origin is absent on many same-origin GETs and has enough historical variation to make a strict rule risky.

Each works. Each is application-level machinery, invented per framework, deployed per endpoint, forgotten on exactly the endpoint that needed it.

The header was proposed for this job eighteen years before it arrived in the form this article is about. Robust Defenses for Cross-Site Request Forgery (Barth, Jackson and Mitchell, CCS 2008) works through the same three defenses, finds shortcomings in each, and then asks whether the browser could simply say where a request came from. The paper's obstacle was empirical: across 283,945 advertisement impressions the authors observed that Referer "is widely blocked at the network layer due to privacy concerns" — the field that carried provenance was being stripped in transit by the network, so a server could not rely on it.

Their conclusion is the sentence this whole article is a sequel to: "we propose that browsers implement the Origin header, which provides the security benefits of the Referer header while responding to privacy concerns." Provenance without the full URL. That is the design Sec-Fetch-Site generalizes, and the interval between the proposal and the deployment is the period during which every application rebuilt the answer by hand.

CSRF token (synchronizer token). A random, unguessable value the server generates per session (or per form), embeds in the page it renders, and requires back on the next state-changing request. One common form is a hidden field:

<form method="post" action="/account/email">
  <input type="hidden" name="csrf_token"
         value="8f14e45fceea167a5a36dedd4bea2543a1b2c9d4e5f6a7b8">
  <input type="email" name="address">
</form>

It defeats CSRF because the attacker's page can cause the request but cannot read your HTML to learn the value — the same-origin policy stops that. The token proves the request came from a document the server itself rendered.

That last sentence is worth holding next to the fetch-metadata claim, because they prove the same fact by opposite means: a token proves provenance by possession of a secret; Sec-Fetch-Site states provenance as testimony. One costs you per-form plumbing and a place to store the secret; the other costs a four-line check.

Warning

Where CSRF tokens collide with a strict CSP. Because the token varies per response, the tempting way to hand it to JavaScript is an inline script:

<script>window.CSRF_TOKEN = "8f14e45f…";</script>   <!-- needs a nonce -->

Under a strict CSP that block does not run unless you stamp it with the response's nonce — and reaching for unsafe-inline to avoid the plumbing destroys the CSP outright. The fix is not to nonce it: don't put the token in a script at all. Put it in markup and read it from the DOM.

<meta name="csrf-token" content="8f14e45f…">
<!-- external, nonce-free, CSP-clean: -->
<!-- document.querySelector('meta[name=csrf-token]').content -->

A meta tag or a data- attribute carries per-response data without executing anything, so the strict policy stays intact and nothing needs a nonce. The general rule: dynamic data belongs in markup, not in generated script.

Session cookie, and what SameSite changes. A session cookie is the ambient credential that makes CSRF possible: the server sets it once at login, and the browser attaches it to every subsequent request to that site — including ones initiated by someone else's page. That automatic attachment is the entire vulnerability.

SameSite tells the browser when to withhold it:

  • SameSite=Strict — never sent on any cross-site request, including a plain link from another site. Safest; the visible cost is that following an external link into your site shows you logged out until the next same-site navigation.
  • SameSite=Lax — withheld from cross-site subresources and non-idempotent requests, but sent on top-level cross-site GET navigations, which is what makes external links feel normal. Chromium treats it as the default when the attribute is absent.
  • SameSite=None — always sent, and only permitted with Secure. Required for genuine cross-site flows (a payment or SSO callback that arrives as a POST from another site).

A hardened session cookie uses Set-Cookie: __Host-session=…; Secure; HttpOnly; SameSite=Strict; Path=/, where HttpOnly keeps script from reading it and the __Host- prefix makes the browser enforce Secure, Path=/, and no Domain attribute. Learn more.

Note

SameSite and fetch metadata solve the same problem from opposite ends, and that is why deploying both is not redundancy. SameSite withholds the credential, so the request arrives unauthenticated; the isolation policy refuses the request, so it is never processed at all. If one is misconfigured on some route — and SameSite=None must exist somewhere in any real SSO or payment integration — the other still holds. Note the overlap is imperfect by design: SameSite is also a site boundary, so it too lets a compromised subdomain through.

What the browser knows and finally says

Fetch Metadata Request Headers (W3C Working Draft) adds four headers that the user agent sets on outgoing requests. The server does not ask for them and the page cannot influence them.

The four headers, their values, and the question each answers. The browser sets all four; because the names carry the Sec- prefix, page script cannot set or override them.
  • Sec-Fetch-Site — the relationship between the initiator's origin and the target's. The spec defines it as exposing "the relationship between a request initiator's origin and its target's origin," with values same-origin, same-site, cross-site, and none. That last one means no initiator — the user typed the URL or opened a bookmark. (browser support)
  • Sec-Fetch-Mode — how the request was made: navigate, cors, no-cors, same-origin, websocket. A top-level page load is navigate; an <img src> is no-cors. (browser support)
  • Sec-Fetch-Dest — what the response will become: document, script, image, iframe, object, embed, empty, and the rest of the Fetch destination list. Sec-Fetch-Dest notices when someone tries to load your JSON endpoint as a <script>. (browser support)
  • Sec-Fetch-User — sent only when a navigation was triggered by user activation, with the value ?1. Its absence on a navigation means no activation gesture caused it. (browser support)

Important

Those four support links are not decoration — check them before you write a rule that depends on one. The first three are Baseline widely available (Chrome 76+/80+, Edge 79+/80+, Firefox 90+, Safari 16.4+) and sit around 93% global support. Sec-Fetch-User is the outlier: Safari does not implement it at all, on desktop or iOS, which puts it near 80%. Treat -Site, -Mode and -Dest as things you may build a policy on, and Sec-Fetch-User as a signal you may log but must never require — a rule that demands ?1 locks out every Safari user.

Imagine a receptionist who, until now, only ever saw the visitor standing at the desk holding a valid keycard. Anyone holding the card got in, because the card was the whole story — it didn't matter whether the holder walked in themselves or was pushed through the door by someone outside.

Fetch metadata is the building finally telling the receptionist four things about each arrival: who sent you, how you got here, what you're planning to be once inside, and did a person actually choose to come. None of it comes from the visitor's mouth. It comes from the building's own cameras — which is exactly why it can be believed.

The everyday version of this is the one in your pocket. Caller ID shows you who is calling before you decide whether to pick up — and crucially, the number comes from the phone network, not from the caller's own claim about themselves. Fetch metadata is caller ID for HTTP requests: the network tells you who is calling, and you choose whether to answer.

That analogy also predicts the mechanism's limits with some precision. Caller ID tells you the calling number, not the person holding the phone (so a compromised subdomain still shows a familiar number). It works because the network, not the caller, supplies it (which is exactly what the Sec- prefix buys). And it tells you nothing about what the caller will say once you answer — that is the CSP's department, and the reason both boundaries exist.

Drilling the four roles

Each header answers a different question, and mixing them up is the most common way a policy ends up checking the wrong thing. Three quick discriminations before the policy that consumes them.

Retrieval. Match the symptom to the header that reveals it.
Discrimination. Two headers, nearly the same word, different jobs.
Transfer. The header that is not what its name suggests.

An open question: what counts as a user?

Sec-Fetch-User is described as marking a request the user caused, and that description is quietly load-bearing in a way worth interrogating now that a growing share of web traffic is agents driving real browsers.

The spec's actual condition is user activation, and activation is defined in terms of trusted events — an event whose isTrusted attribute is true, meaning the browser itself generated it from input rather than script having dispatched it. So the question becomes: when an AI agent clicks a link, is that a trusted event?

It depends entirely on how the agent clicks, and the two answers are opposite:

  • Script-synthesized — the agent runs element.click() or dispatches a MouseEvent. That event has isTrusted: false, grants no activation, and a resulting navigation carries no Sec-Fetch-User.
  • Driven through the automation protocol — the agent instructs the browser to click at a coordinate, as browser-automation tooling does. The input enters below the event layer, so the browser generates the event itself. It is trusted, activation is granted, and the navigation carries Sec-Fetch-User: ?1 — indistinguishable from a human.

I checked the second case rather than assuming it: an agent-driven click reported isTrusted=true with navigator.userActivation.isActive=true, and the navigation it caused arrived with Sec-Fetch-User: ?1. A synthetic .click() on the same element reported isTrusted=false and produced no activation.

Sec-Fetch-User answers "was there an input gesture?" — not "was there a human?" The spec never promised the second question. Fetch Metadata defines no header that answers it.

There is a telling detail in the standard here. Its WebDriver extension lets automation consume an existing activation but provides no way to create one — the platform declines to manufacture activation at the API level, while synthesized-at-the-input-layer activation is real by construction. The distinction the spec draws is between script and input, not between machine and person.

Note

So: the header is honest about what it measures, and what it measures is not agency. If you are tempted to use Sec-Fetch-User as a bot signal, don't — it fails in both directions, marking agent-driven clicks as user-caused while (per the browser-support table in this section) never appearing in Safari at all. Whether the platform should eventually distinguish human from agent provenance — and whether a header is even the right place, given that browsers have historically refused to make automation detectable — is genuinely unsettled. I don't have a confident answer, and I'd rather leave the question standing than pretend the current headers close it.

The word "site" is doing a lot of work

Sec-Fetch-Site reports a site relation, and "site" is not a synonym for "origin." The difference decides which attacks the policy stops, so it is worth getting exactly right before writing any rule that reads the header.

An origin is a triple: scheme, host, and port. All three must match. A site is coarser — the scheme plus the registrable domain (eTLD+1). Subdomains and ports don't enter into it.

eTLD+1 — the "effective top-level domain" plus one label. For example.com the eTLD is .com, so the site is example.com. The Public Suffix List exists because .co.jp and .github.io behave like TLDs: it makes a.github.io and b.github.io different sites, which is why user content on a shared host isn't automatically same-site with its neighbors. Learn more.

Origin versus site, read as a column selection over one URL. An origin comparison consults the scheme, the whole host, and the port; a site comparison consults the scheme and the registrable domain only. Read straight down from www: bracketed for origin, ignored for site.

Two URLs make the whole distinction concrete. https://www.example.com and https://login.example.com are same-site but cross-origin: same scheme, same eTLD+1, different host. Everything in this article turns on that row.

Worked pairs, adapted from the web.dev reference on same-site and same-origin. The middle column is the one that surprises people.
URL A URL B same-origin? same-site?
https://www.example.com:443 https://www.example.com ✓ (implicit :443)
https://www.example.com https://login.example.com ✗ different subdomain subdomains ignored
https://www.example.com:443 https://www.example.com:80 ✗ different port ports ignored
https://www.example.com http://www.example.com ✗ different scheme scheme counts
https://www.example.com https://www.evil.example
https://a.github.io https://b.github.io ✗ *eTLD is .github.io *

That fourth row is schemeful same-site: the scheme is part of a site, so an http:// page and its https:// twin are cross-site. The older scheme-blind reading is now called schemeless same-site.

Think of an origin as a specific apartment — building, floor, and unit number all have to match. A site is the whole building. The doorman who says "same-site" is telling you the visitor came from somewhere in this building. He is not telling you they came from your apartment.

That's a useful thing to know and a dangerous thing to over-trust. If a neighbor's flat has been broken into, the intruder is still, truthfully, somewhere in this building.

Warning

Because the header speaks in sites, a compromised subdomain reports same-site and passes rung two of the Resource Isolation Policy. If status.example.com is a third-party status page or a legacy app you don't control, it is inside your isolation boundary. Fetch metadata draws a site boundary; if you need an origin boundary, you must check Origin yourself.

Discrimination. Commit before reading on — this is the distinction the rest of the article depends on.

Why the prefix is the security property

The mechanism only works if the values are honest, and honesty here is structural rather than aspirational. All four names begin with Sec-, which makes them forbidden header names under Fetch — script cannot set them through fetch(), XMLHttpRequest, or any other page-reachable API. The spec states the consequence directly:

This will prevent malicious websites from convincing user agents to send forged metadata along with requests, which should give sites a bit more confidence in their ability to respond reasonably to the advertised information.

That is the load-bearing difference between fetch metadata and, say, a custom X-Requested-With header. Both are "a header the server checks." Only one of them is a header the attacker's page is structurally unable to produce.

What forging actually looks like

"Script cannot set them" invites a reasonable question: cannot, how? Does it throw? It is worth watching, because the answer is silence, and silence is the failure mode that fools people.

Here is the attempt, and what the server on the other end actually received:

// On https://evil.example — trying to look same-origin to the target.
await fetch('https://echo.example/', {
  headers: {
    'Sec-Fetch-Site': 'same-origin',  // the lie
    'Sec-Fetch-Dest': 'script',       // another lie
    'X-Custom': 'yes',                // an ordinary header, for contrast
  },
});

An attacker's page tries to label its own request as trustworthy. Nothing throws.

Sec-Fetch-Dest: empty        ← asked for "script"
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site   ← asked for "same-origin"
X-Custom: yes                ← arrived exactly as written

What the server saw. The two Sec- headers were replaced by the browser's own truthful values; the ordinary header passed through untouched.

No exception, no console warning, no rejected promise. The request went out and succeeded — carrying the truth. X-Custom is the control that proves the API was working normally: page script can set headers, just not these.

The mechanism is the Fetch spec's header guard, and it has one sharp edge worth seeing, because it is how a developer talks themselves into believing the forgery worked:

// Guard "none" — a bare Headers bag keeps whatever you put in it.
new Headers({ 'Sec-Fetch-Site': 'same-origin' }).get('Sec-Fetch-Site');
// → 'same-origin'   ...so far, so encouraging.

// Guard "request" — attached to a Request, the forbidden name is dropped.
new Request('https://echo.example', {
  headers: { 'Sec-Fetch-Site': 'same-origin', 'X-Custom': 'yes' },
}).headers.get('Sec-Fetch-Site');
// → null            (X-Custom survives)

// XMLHttpRequest is equally quiet — a no-op, not a throw.
xhr.setRequestHeader('Sec-Fetch-Site', 'same-origin');  // returns normally

A standalone Headers object has guard none and accepts anything. Only when it is attached to a request does the request guard filter it.

Note

The first call is the trap. A developer prints the Headers object, sees Sec-Fetch-Site: same-origin sitting there, and concludes the header is forgeable. It isn't — the filter runs at the boundary where the request is constructed, not where the bag is filled. Read the wire, not the object.

The practical consequence for a server is a strong one. There is no page-reachable API that produces a false Sec-Fetch-* value, so the values a browser sends are authentic by construction: a forged value is unreachable through the platform rather than merely discouraged.

Important

The headers are sent only to potentially trustworthy targets — in practice, HTTPS. A server reached over plain HTTP receives nothing, which is why the Resource Isolation Policy must treat "header absent" as allow rather than deny, and why this defense presumes TLS everywhere.

That presumption has a consequence people skip past. If an attacker can get a victim's browser to make a plain-HTTP request to your host, the headers vanish and the request lands on the fail-open rung. The redirect you serve from port 80 does not help: the first request already left the device, cookies and all.

The control that closes this is HSTS — a response header that tells the browser to refuse plain HTTP to your host for a given duration, upgrading such requests before they are sent:

Strict-Transport-Security: max-age=31536000; includeSubDomains

HSTS — HTTP Strict Transport Security (RFC 6797). After one HTTPS visit, the browser remembers for max-age seconds that this host is HTTPS-only and rewrites http:// requests to https:// locally, before any bytes leave. Learn more.

But HSTS is trust on first use: the browser only learns the rule after one successful HTTPS visit, so a victim's very first request to your host is still unprotected. The HSTS preload list removes that gap by shipping your domain inside the browser itself, so the protection applies before the first visit ever happens. Preloading requires max-age of at least one year, plus includeSubDomains and preload on the header, and submission at hstspreload.org.

Warning

Preloading is close to a one-way door. Every subdomain must serve valid HTTPS forever, and removal propagates on browser-release timescales — months, not minutes. It is the right default for a domain you fully control; it is a trap if includeSubDomains would cover an internal host that still speaks HTTP. Audit every subdomain before submitting, not after. There is a fuller treatment of the preload commitment and how to unwind it coming in a piece of its own.

The Resource Isolation Policy

With provenance in hand, the rule almost writes itself. Google's resource-isolation guidance gives it as a short ladder: let through the traffic that has a legitimate reason to be cross-site, and reject the rest.

The Resource Isolation Policy as a decision ladder. Each rung admits a class of legitimate traffic; what falls off the bottom is a cross-site request for a resource that was never meant to be reachable cross-site.
  1. No Sec-Fetch-Site? Allow. An older browser, a plain-HTTP request, or a non-browser client. You cannot deny on evidence you didn't receive without breaking those clients. The compatibility analysis later in this section examines this rung.
  2. same-origin, same-site, or none? Allow. Your own pages, your own subdomains, and requests with no initiator at all — a typed URL or a bookmark.
  3. A top-level navigation? Allow — that is, Sec-Fetch-Mode: navigate with a GET, provided Sec-Fetch-Dest is not object or embed. Another site linking to you is the web working correctly. The object / embed carve-out matters because those destinations load content in ways that have historically evaded the protections a normal navigation gets.
  4. Everything else: reject with 403. What remains is a cross-site, non-navigational request — the exact shape a forged request or a cross-site probe has to take.
if Sec-Fetch-Site is absent                          → allow
if Sec-Fetch-Site in {same-origin, same-site, none}  → allow
if Sec-Fetch-Mode == navigate and method == GET
   and Sec-Fetch-Dest not in {object, embed}         → allow
otherwise                                            → 403

A resource isolation policy in the shape the guidance describes. It runs before authentication.

Warning

Run this check before authentication, not after. The guidance is explicit: "Make sure that you reject invalid requests before running authentication checks." A rejection that happens after a session lookup can differ observably — in timing, or in error shape — between a valid and an invalid session, which hands back a fraction of the very signal the policy exists to withhold.

The ordering point deserves a moment, because it is the kind of detail that survives code review and fails in production. If your handler authenticates first and isolates second, then an attacker's cross-site request still causes a session lookup. Whether that lookup is fast or slow, cached or uncached, is information — and information that varies with the victim's state is precisely what a cross-site leak is made of. Isolation belongs at the front door.

Where curl fits, and why rung one isn't the hole it looks like

Run curl -v https://example.com and watch what goes out:

> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.7.1
> Accept: */*

Everything curl sends by default. There is no fetch metadata here, and there never will be unless you type it yourself.

So every CLI client, every server-to-server call, every health check and RSS fetcher lands on rung one and is allowed through. And of course an attacker can run curl too — and can pass -H 'Sec-Fetch-Site: same-origin', which no forbidden-header rule prevents, because the rule binds browsers, not sockets.

At which point the policy looks worthless. It isn't, and seeing why is the central intuition of the whole mechanism.

Fetch metadata does not defend against an attacker who sends requests. It defends against an attacker who makes your user's browser send them.

The attack this stops is a confused deputy: the browser is a deputy holding the victim's credentials, and the attacker's page tricks it into spending them. When an attacker uses curl, they are no longer holding anyone's cookies — they are just an anonymous stranger at the door, and the endpoint's ordinary authentication turns them away exactly as it always did. Forging same-origin from curl gains nothing, because the label was never the thing being checked for authority. The session cookie was, and curl doesn't have one.

Confused deputy — a program with authority of its own that a less-privileged caller tricks into using it on the caller's behalf. Named by Norman Hardy in 1988. Learn more.

Which clients label their requests, and which hold the credential that makes a forgery worth attempting. The two columns are almost inverses of each other, and that is the whole reason the fail-open rung is safe.

The columns are near-inverses: the client that carries ambient credentials is the one that labels its requests honestly, and the clients that could lie have nothing to gain by it. That is why "absent means allow" is not the loophole it appears to be.

Note

The compatibility split also locates the defense boundary. If an endpoint is reachable by a client that authenticates with a bearer token — an API key, an OAuth token in an Authorization header — fetch metadata is not protecting it, because such a client sends no fetch metadata and an attacker who has stolen the token doesn't need a victim's browser. Ambient credentials (cookies) are the thing this defends; explicit credentials need their own controls.

When to stop allowing unlabeled requests

The fail-open rung is prudent guidance, not a permanent setting, and treating it as permanent is the mistake. It exists to protect two populations: browsers too old to send the headers, and non-browser clients that never will. Those are very different, and they expire on very different schedules.

Sec-Fetch-Site has been sent by every major browser engine since Safari 16.4 in March 2023, and sits near 93% of global traffic — the remaining share is overwhelmingly non-browser clients and locked-down legacy devices, not users of browsers released since March 2023. So the honest question is no longer "can I afford to deny unlabeled requests?" but "which of my endpoints are reachable by a browser at all?"

That gives a rule with an actual decision in it:

  • An endpoint only ever called by your own pages — a session-cookie-authenticated form post, a same-origin JSON API — has no legitimate unlabeled caller once your browser floor is at Safari 16.4. Requiring the header here costs you nothing real and closes the last soft edge. Do it.
  • An endpoint with genuine programmatic callers — a public API, a webhook receiver, a feed — must keep the fail-open rung, because its legitimate traffic is unlabeled by nature. Gate those with tokens and Origin checks instead; provenance was never going to be their control.
  • If you simply don't care about pre-2023 browsers, say so explicitly in the policy and deny unlabeled requests on cookie-authenticated routes. An unstated compatibility promise is how a temporary allowance becomes permanent.

Important

Before you tighten this, measure it. The deployment section's report-only rollout shows exactly which paths receive unlabeled requests and from what. Flip the rung per route, on evidence — never globally on a hunch, and never without the log that shows what you are about to break.

What this actually stops

The honest way to describe a defense is by naming the attacks it eliminates and the ones it leaves standing. Side by side, because the second column is what decides whether you still need the rest of your security envelope.

What the Resource Isolation Policy covers, and what it does not. A ✓ means the policy alone refuses the request; a ✗ means you need a different control, named in the last column.
Attack or case Stopped? Why What covers the gap
CSRF — forged state-changing POST cross-site and non-navigational
XSSI — your JSON loaded as <script> Dest: script + Site: cross-site
XS-Leaks by response probing uniform 403, no session consulted
Framing via cross-site iframe refusable on Dest: iframe frame-ancestors as well
XSS — injected script in your page same-origin response, correctly admitted a strict CSP
Plain-HTTP requests headers absent by design HSTS (and preload)
Compromised subdomain reports same-site, passes rung two Origin check; per-origin isolation
Stolen bearer token no browser, no ambient credential token scope, rotation, binding
Legitimate cross-origin API n/a must be exempted by hand each exemption is a chosen hole

Read the ✗ rows as a specification for the rest of your defenses rather than as a disappointment. A boundary that claimed all nine rows would be a boundary lying about one of them.

Two of those rows deserve a sentence more. Same-site attacks are the sharp edge of the site-versus-origin distinction from earlier: a compromised subdomain sends same-site and sails through rung two, because fetch metadata draws a site boundary and not an origin one. And legitimate cross-origin APIs — a public CORS endpoint, a favicon, an embeddable widget — must be exempted explicitly; each exemption is a hole, and the only safe kind of hole is one written down in a list somebody reviews.

Note

The XS-Leaks wiki is explicit that this should "not be seen as a replacement" for SameSite cookies, COOP, or framing protections. Those are enforced by the browser on the client; this is enforced by you on the server. Defenses at different layers fail in different ways, which is the entire argument for having both.

Two boundaries, two questions

The two-boundary model connects the article to the rest of the series. It is tempting to file fetch metadata under "more security headers, like CSP" — and that framing will cost you, because the two answer questions on opposite sides of the wire.

The two enforcement boundaries. Fetch metadata is evaluated on the server, before a response exists; a Content Security Policy is evaluated in the browser, after the response arrives. Neither can do the other's job.

A strict CSP answers: may this code execute in this document? It is enforced by the browser, after your bytes have already been sent, and its currency is nonces and hashes — proof that the server vouched for a specific script.

A resource isolation policy answers: will I answer this request at all? It is enforced by your server, before any response exists, and its currency is the browser's testimony about where the request came from.

Neither substitutes for the other. A perfect CSP does not stop a forged state-changing POST, because that request never involves executing a script in your page — it involves your own endpoint doing exactly what it was built to do, for the wrong reason. A perfect isolation policy does not stop stored XSS, because that payload arrives inside a same-origin response the policy is right to admit.

CSP constrains what your document may run. Fetch metadata constrains what your server will dignify with an answer. Confusing them leaves one of the two doors open.

Prediction checkpoint. Commit before reading the next section.

Deploying it without breaking your site

The rollout is the same shape as a CSP rollout, for the same reason: you do not know your own long-tail traffic until you measure it.

  1. Implement the ladder in report-only form. Log what would have been rejected — path, Sec-Fetch-Site, -Mode, -Dest — and reject nothing.
  2. Read the log for legitimate cross-site traffic. Public APIs, embeddable content, favicons, RSS readers, link previews, payment callbacks. Each one is either a real exemption or a resource that should never have been reachable cross-site.
  3. Add the exemptions explicitly, as a list you can review, not as a permissive default.
  4. Turn on enforcement, and keep the log.

Important

Add the fetch-metadata header names to Vary on cacheable responses. A shared cache that stores one response for a same-origin request and replays it for a cross-site one has quietly undone the policy — and, worse, done so in a way that looks like a caching bug rather than a security failure.

That last point is the one that generalizes past this article, and it should feel familiar from the caching piece: any time a response depends on a request header, a cache that ignores that header is a correctness bug wearing a performance costume.

The checklist

The table collects the article's guidance for use against a real deployment. The right column is not a list of style preferences — each entry is a failure someone has actually shipped.

The practices this article argues for, paired with the specific mistake each one prevents.
Do Don't Because
Run the isolation check before authentication Authenticate, then isolate A post-auth rejection varies with session state — timing and error shape leak the signal you meant to withhold
Build rules on -Site, -Mode, -Dest Require Sec-Fetch-User Safari never sends it; a rule demanding ?1 locks out every Safari user
Treat absent headers as allow by default Leave it that way forever The population it protects is now mostly non-browser clients — tighten cookie-authed routes on evidence
Deny unlabeled requests on cookie-authed routes, once measured Deny globally on a hunch Public APIs, feeds and webhooks are unlabeled by nature and will break
Add the fetch-metadata names to Vary Cache header-dependent responses without it A shared cache replays a same-origin response to a cross-site requester and silently undoes the policy
List exemptions explicitly Add a permissive default An exemption you can review is a hole you chose; a default is one you inherited
Check Origin too, when you need an origin boundary Assume same-site means "mine" A compromised subdomain reports same-site and passes rung two
Ship SameSite cookies and the isolation policy Treat either as making the other redundant They withhold the credential and refuse the request — different failure modes, deliberately overlapping
Put per-response tokens in markup (meta / data-) Emit them from an inline <script> An inline script needs a nonce, and reaching for unsafe-inline to avoid the plumbing destroys the CSP
Serve HSTS, and consider preloading Rely on a port-80 redirect The first plain-HTTP request already left the device, cookies and all
Roll out in report-only first Enforce on day one You do not know your own long-tail cross-site traffic until you have measured it
Carve out object and embed on navigations Allow every cross-site GET navigation Those destinations load content in ways that have historically evaded normal navigation protections

The shape of the whole thing

For twenty years, the web's answer to "where did this request come from?" was that the server should figure it out from evidence it never received. We built token schemes, cookie-comparison tricks, and header heuristics — all of them reconstructions of a fact the browser had the entire time and had no vocabulary to express.

Fetch metadata is the vocabulary. It does not make requests safer; it makes them legible. The security comes from what you do with the legibility — and the first thing worth doing is refusing to answer questions that no legitimate caller would have asked.

Lessons

  • A forged cross-site request is byte-identical to a real one by construction: same method, path, cookies, and Host. Classical CSRF defenses are all attempts to reconstruct the missing provenance in application code.
  • Four headers supply it directly. Sec-Fetch-Site (initiator relationship), -Mode (how), -Dest (what it will become), -User (was there a human gesture). The browser sets them; the Sec- prefix makes them forbidden header names, so page script cannot forge them.
  • "Site" is not "origin." An origin is scheme + host + port; a site is scheme + eTLD+1, so www.example.com and login.example.com are same-site but cross-origin. The header speaks in sites, which is why a compromised subdomain passes the policy.
  • Forging fails silently, not loudly. A Sec- header set through fetch() or XHR is dropped without an exception — and a standalone Headers object will happily show you the value you set, which is the trap. Read the wire.
  • They are sent only to HTTPS targets, so "header absent" must mean allow — which makes this a defense that presumes TLS, and makes HSTS (ideally preloaded) part of the same control rather than a separate concern.
  • The unlabeled-request rung is a schedule, not a setting. It protects pre-2023 browsers and non-browser clients; once your browser floor clears Safari 16.4, cookie-authenticated routes can require the header — measured per route, never globally on a hunch.
  • The mechanism defends the confused deputy, not the door. curl sends no fetch metadata and can forge any header — and gains nothing, because it holds no victim's cookies. What this stops is an attacker spending someone else's ambient credentials.
  • Sec-Fetch-User measures input, not humanity. An agent driving a real browser produces genuine user activation and sends ?1; a script-synthesized click sends nothing. No current header distinguishes agent from person.
  • The Resource Isolation Policy is four rungs: allow when the header is absent; allow same-origin / same-site / none; allow top-level GET navigations that aren't object or embed; reject everything else with 403.
  • Reject before you authenticate. A rejection that happens after a session lookup can leak, through timing or error shape, exactly the signal the policy exists to withhold.
  • It stops CSRF, XSSI, and a broad class of XS-Leaks — not XSS. CSP governs what may execute in a document; fetch metadata governs whether a response exists at all. The two boundaries sit on opposite sides of the wire and cannot substitute for each other.
  • Vary on the fetch-metadata headers, or a shared cache will replay a same-origin response to a cross-site requester and silently undo the policy.

Practice

Retrieval. Recover the mechanism that makes the headers trustworthy.
Discrimination. Separate the request that must be allowed from the one that must not.
Transfer. Diagnose a deployment that passes review and fails in production.

References

  1. W3C. “Fetch Metadata Request Headers.” W3C Working Draft, 2025. — the header definitions, value lists, and the forbidden-header-name argument
  2. Google. “Protect your resources from web attacks with Fetch Metadata.” web.dev. — the Resource Isolation Policy ladder and its deployment advice
  3. WHATWG. “Fetch Standard.” WHATWG. — forbidden request headers and the request destination list
  4. Fetch Metadata.” XS-Leaks Wiki. — the attack classes covered and the explicit limits of the defense
  5. OWASP. “Cross Site Request Forgery.” OWASP. — the attack this replaces a workaround for
  6. MDN. “Sec-Fetch-Site.” MDN. — the reference for each header and its browser support
  7. W3C. “Content Security Policy Level 3.” W3C. — the other boundary, for contrast
  8. Google. “Understanding "same-site" and "same-origin".” web.dev. — the origin/site distinction and the worked comparison pairs
  9. Sec-Fetch-Site browser support.” Can I use. — per-header support data, checked before relying on any one of them
  10. Mozilla. “Public Suffix List.” publicsuffix.org. — what makes an eTLD, and why a.github.io and b.github.io are different sites
  11. WHATWG. “User activation.” HTML Standard. — the trusted-event definition behind Sec-Fetch-User, and the agent question it leaves open
  12. IETF. “HTTP Strict Transport Security (HSTS).” RFC 6797, 2012. — the header this defense presumes, and its trust-on-first-use gap
  13. Google. “HSTS Preload List Submission.” hstspreload.org. — closing the first-visit gap, and the commitment that entails
  14. OWASP. “Cross-Site Request Forgery Prevention Cheat Sheet.” OWASP. — synchronizer tokens, double-submit cookies, and how they compose with SameSite

How to cite

APA
Mangalapilly, Y. J. (2026, July). Where Did This Request Come From?. Saṃhitā Notes. https://yesudeep.com/blog/where-did-this-request-come-from/
BibTeX
@online{mangalapilly2026where,
  author  = {Yesudeep Jose Mangalapilly},
  title   = {Where Did This Request Come From?},
  journal = {Sa\d{m}hit\=a Notes},
  year    = {2026},
  month   = {July},
  url     = {https://yesudeep.com/blog/where-did-this-request-come-from/},
  urldate = {2026-08-12},
}
Plain
Yesudeep Jose Mangalapilly. “Where Did This Request Come From?.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/where-did-this-request-come-from/.
RIS
TY  - ELEC
AU  - Mangalapilly, Yesudeep Jose
TI  - Where Did This Request Come From?
T2  - Saṃhitā Notes
PY  - 2026
UR  - https://yesudeep.com/blog/where-did-this-request-come-from/
Y2  - 2026-08-12
ER  - 

Annotations

Thank you — your note is held for review and will appear once approved.

Thank you — your note is published.

Please sign in below to leave a note.