Sec-Fetch Metadata Headers fetch-metadata request
Four browser-set headers that tell servers exactly where a request is coming from and why — enabling server-side isolation policies.
What Sec-Fetch headers are
The Sec-Fetch-* headers are a set of four request headers that browsers automatically attach to every fetch request. They tell the server the context of the request: what type of resource is being fetched, how it's being fetched, where it originated from, and whether a user gesture triggered it.
Servers can use this information to implement Fetch Metadata Request Policies — deciding whether to serve a request based on its context. A request for your admin dashboard from an <img> tag on another site? Reject it. A request from your own JavaScript in the browser? Allow it.
The Sec- prefix marks these as "forbidden headers" — JavaScript can't set or override them. They're always set by the browser, making them a trustworthy source of request context.
The 4 Sec-Fetch headers
Sec-Fetch-Dest
What kind of resource is being requested.
The browser knows what type of resource the fetch is for (a script? an image? a document?) and includes it here:
| Value | Trigger |
|---|---|
document |
Top-level navigation (<a> click, window.location) |
iframe |
<iframe src="..."> |
script |
<script src="..."> |
style |
<link rel="stylesheet"> |
image |
<img src="...">, CSS background-image |
font |
@font-face |
fetch |
fetch() or XMLHttpRequest |
audio |
<audio src="..."> |
video |
<video src="..."> |
worker |
new Worker() |
serviceworker |
Service worker registration |
empty |
Fetch with no specific destination (e.g., fetch() with no dest type) |
Example use: Your API endpoint should only ever receive Sec-Fetch-Dest: empty (from fetch() calls) or document (from direct navigation). If it's receiving Sec-Fetch-Dest: image, something fishy is happening — another site is embedding your API URL in an <img> tag to probe it.
Sec-Fetch-Mode
How the request is being made — what CORS mode it's in.
| Value | Meaning |
|---|---|
navigate |
Browser navigation (clicking a link, form submission) |
cors |
CORS-mode fetch (expects CORS headers back) |
no-cors |
No-CORS mode fetch (opaque response, from <img>, <script>, etc.) |
same-origin |
Fetch that must be same-origin or it fails |
websocket |
WebSocket connection |
Example use: Your API should typically only accept cors or navigate. If you see no-cors, a cross-origin page is embedding your URL in a <img> or <script> tag — the request gets through but JS on that page can't read the response. Still worth knowing about.
Sec-Fetch-Site
The relationship between the request origin and your site.
| Value | Meaning |
|---|---|
same-origin |
Exact same origin (scheme + host + port) |
same-site |
Same registrable domain (app.example.com → api.example.com) |
cross-site |
Different site entirely (evil.com → yoursite.com) |
none |
User-initiated navigation (typing URL, bookmark, opening new tab) |
This is the most useful header for resource isolation. A request from cross-site for your admin dashboard is always suspicious.
Sec-Fetch-User
Whether a user gesture triggered the navigation.
| Value | Meaning |
|---|---|
?1 |
User gesture triggered this (click, keyboard input, etc.) |
| (absent) | No user gesture — programmatic navigation |
Only present on navigate mode requests. ?1 is the structured fields syntax for "true". If Sec-Fetch-User is absent, the navigation was triggered by JavaScript, not by the user directly.
Example use: A login page redirect triggered by window.location = '/login' from a cross-site script (no Sec-Fetch-User) looks different from a user clicking a link to /login (Sec-Fetch-User: ?1).
Implementing a Resource Isolation Policy
The power of these headers comes from combining them. A simple but effective policy:
// Laravel middleware example
public function handle(Request $request, Closure $next)
{
$dest = $request->header('Sec-Fetch-Dest');
$mode = $request->header('Sec-Fetch-Mode');
$site = $request->header('Sec-Fetch-Site');
// Allow: same-origin requests always
if ($site === 'same-origin') {
return $next($request);
}
// Allow: direct navigation (bookmark, typed URL)
if ($site === 'none' && $mode === 'navigate') {
return $next($request);
}
// Allow: user-initiated cross-site navigation to top-level pages
if ($mode === 'navigate' && $dest === 'document') {
return $next($request);
}
// Block: cross-site requests to non-navigable endpoints
abort(403, 'Forbidden by resource isolation policy');
}
This rejects cross-site requests from <img>, <script>, <iframe>, or fetch() while allowing normal browser navigation and same-origin requests.
What this protects against
CSRF attacks. A cross-site form POST or script-triggered request will have Sec-Fetch-Site: cross-site. Your server can detect and reject it without relying solely on CSRF tokens (though keep tokens too — defence in depth).
Cross-site information leakage. An attacker using <img src="https://yourapi.com/user/me"> to probe your API will trigger Sec-Fetch-Dest: image, Sec-Fetch-Site: cross-site. You can reject it before any processing happens.
Clickjacking via unexpected request context. If your checkout endpoint is receiving Sec-Fetch-Dest: iframe from a cross-site origin, someone is trying to embed it.
Browser support
All major browsers send Sec-Fetch-* headers: Chrome 80+, Edge 80+, Firefox 90+, Safari 16.4+. Older browsers and non-browser HTTP clients don't send them. Your policy must allow requests without these headers (server-side tools, old browsers) or handle them separately.
A safe approach: only reject requests that have these headers AND indicate suspicious context. Don't reject requests that lack them entirely.
Real-world examples
fetch() from your own JavaScript:
POST /api/checkout HTTP/1.1
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
→ Allow: same-origin CORS fetch
User clicking a link to your site from Google:
GET /landing-page HTTP/1.1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1
→ Allow: user-initiated cross-site navigation to a document
Attacker embedding your API in an img tag:
GET /api/user/profile HTTP/1.1
Sec-Fetch-Dest: image
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site
→ Reject: API endpoint should never be fetched as an image from cross-site
Attacker's script making a cross-site fetch:
POST /api/transfer HTTP/1.1
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
→ Reject (unless your API explicitly allows cross-origin requests from this origin via CORS)
FAQ
Can Sec-Fetch headers replace CSRF tokens?
They can provide additional CSRF protection, but don't replace tokens entirely. Older browsers don't send Sec-Fetch-*. Your CSRF token strategy covers those cases. Use both — Sec-Fetch headers make CSRF protection more robust; tokens are the fallback.
Why the Sec- prefix?
The Sec- prefix designates "forbidden headers" — headers browsers prevent JavaScript from setting. This ensures the metadata is trustworthy: your server knows the context came from the browser, not from JavaScript trying to spoof it. A script can't send Sec-Fetch-Site: same-origin to pretend it's same-origin.
Do Sec-Fetch headers work on API-to-API requests?
No — they're browser-only. Server-side HTTP clients (Laravel's HTTP client, axios in Node.js, curl) don't send them. Your isolation policy must account for this and allow requests without Sec-Fetch-* for legitimate non-browser clients.
Fun fact
The Sec-Fetch-* headers were proposed by Google's security team in 2018 specifically to give servers a reliable, browser-enforced signal about request origin context — something that Referer and Origin provided partially but inconsistently. The spec went through the W3C Web Application Security Working Group and became a standard in 2019. Unlike many security headers that took years to reach broad browser adoption, all major browsers shipped Sec-Fetch-* support within about 2 years of the spec being finalised — unusually fast, driven by the clear security value and low implementation complexity.