Back to HTTP Headers

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.comapi.example.com)
cross-site Different site entirely (evil.comyoursite.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.