TulipLot works best in Chrome or a Chromium-based browser. Some features may be limited here.
← All posts
Embedding · · 9 min read

The headers that tell a server it is being framed

Checked on 2026-08-24.

Every request your browser makes carries a short description of itself: what the response will be used as, what kind of request it is, and whose site asked for it. Four headers carry that, and they all start with Sec-Fetch-.

The Fetch Metadata Request Headers specification states the goal in its abstract. The headers "aim to provide servers with enough information to make a priori decisions about whether or not to service a request based on the way it was made..." A priori is the load-bearing phrase: the server decides before it does the work.

If your page doesn't want to be framed, two values settle it. Load it into a cross-site <iframe> and the request arrives carrying Sec-Fetch-Dest: iframe and Sec-Fetch-Site: cross-site. Your server can read those two lines and answer 403. No X-Frame-Options. No frame-ancestors. The decision moves off the browser and onto the server, and that changes what can undo it.

The four headers

The specification defines each one in a sentence.

Sec-Fetch-Dest "exposes a request's destination to a server" — what the returned bytes get used as: image for an <img>, iframe for an <iframe>, document for a top-level navigation, empty for a fetch() with no destination of its own.

Sec-Fetch-Mode carries the mode: cors, navigate, no-cors, same-origin, or websocket. It's what separates a document navigation from a subresource load.

Sec-Fetch-Site "exposes the relationship between a request initiator's origin and its target's origin": same-origin, same-site, cross-site, or none. That last one means no site asked — someone typed the address or opened a bookmark. The specification gives that case its own section: no site is responsible, so a server may treat it as trusted.

Sec-Fetch-User "exposes whether or not a navigation request was triggered by user activation," and it's the odd one out. It only ever carries a true value, ?1. The specification notes that "The header is delivered only for navigation requests, and only when its value is true," so absence is the negative signal; there's no ?0.

What a framed request actually looks like

Two facts combine. The Fetch Standard defines a navigation request as one whose destination is document, embed, frame, iframe, or object — so a frame load is a navigation. And in the HTML Standard's navigation algorithm, the branch for a navigable inside a container element says: "Set request's destination to navigable's container's local name."

An <iframe> element's local name is iframe. So a cross-site frame load produces this:

GET /dashboard HTTP/1.1
Sec-Fetch-Dest: iframe
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site

A person clicking a link to that page, from that same other site, produces this:

GET /dashboard HTTP/1.1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Fetch-User: ?1

Same site relationship. Same mode. Sec-Fetch-User marks the click here, but it doesn't discriminate either: HTML sets the request's user-activation from the source document's transient activation, before the algorithm looks at the container. A frame load fired from a click handler carries Sec-Fetch-User: ?1 too. So Sec-Fetch-Dest is the only value separating the frame from the visit, and a policy written on Sec-Fetch-Mode alone won't do this job.

Don't check for iframe alone, either: by the same local-name rule, <frame> yields frame, <object> yields object, and <embed> yields embed.

Nothing on the page can forge them

This is what makes them worth trusting. They're forbidden request headers — per MDN's glossary, "an HTTP header name-value pair that cannot be set or modified programmatically in a request," because "the user agent retains full control over them."

It isn't a special case for these four but a prefix rule, in the Fetch Standard's steps for deciding whether a header is a forbidden request-header: "If name when byte-lowercased starts with proxy- or sec-, then return true." Fetch says why the names are shaped this way. "Header names starting with Sec- are reserved to allow new headers to be minted that are safe from APIs using fetch that allow control over headers by developers, such as XMLHttpRequest."

"Forbidden" has a narrow, mechanical meaning here. It bites on the Headers object your script builds: Fetch's validation step says that if the object's guard is request and the pair is a forbidden request-header, the step returns false, and append then returns having done nothing. The header is dropped silently. Your fetch() call doesn't throw — it goes out carrying the browser's value instead of yours.

One wrinkle, since it's easy to trip over. The Fetch Metadata specification's own section on the prefix says the Sec- names are "forbidden response-header names, and therefore unmodifiable from JavaScript." Read that against Fetch, which defines forbidden response-header name exhaustively — Set-Cookie and Set-Cookie2, nothing else — and puts the sec- prefix in the forbidden request-header steps quoted above. These headers aren't in the definition that sentence points at, so read the word as "request". MDN's guide states it that way: "Like all Sec- prefixed headers, these are forbidden request headers." The intent in the specification's very next sentence isn't in doubt either: the prefix "will prevent malicious websites from convincing user agents to send forged metadata along with requests."

A resource isolation policy

MDN calls the common shape a resource isolation policy: the server "examines the request's fetch metadata headers" and allows same-origin traffic, deliberately exposed cross-origin endpoints, and "Top-level navigational requests from another origin, so users can get to your site by clicking links in other sites."

That last allowance is where framing hides, so an anti-framing version must name the destination:

FRAME_DESTS = {"iframe", "frame", "object", "embed"}

function allow(req):
    site = req.header("Sec-Fetch-Site")
    dest = req.header("Sec-Fetch-Dest")

    if site is absent:
        return LEGACY_DEFAULT        # decide this on purpose; see below

    if site == "same-origin" or site == "none":
        return true                  # our own pages; the address bar

    if site == "same-site":
        return TRUST_OWN_SUBDOMAINS

    # from here down, the request is cross-site
    if dest in FRAME_DESTS:
        return false                 # the refusal

    if dest == "document" and req.method == "GET":
        return true                  # someone clicked a link to us

    if req.path in PUBLIC_CORS_ENDPOINTS:
        return true

    return false

MDN's own example policy stops one clause short: it allows any cross-site request whose mode is navigate and method is GET, and a frame load satisfies both. That's right for its stated purpose — blocking cross-origin attacks, not framing — and it isn't one page's slip. MDN links onward to the XS-Leaks wiki's Resource Isolation Policy, whose recipe carries the same clause with the consequence spelled out in its comment: "Allow simple top-level navigations, this includes embeds." The permissive version is the standard advice, deliberately. Add the destination test when framing is what you're refusing.

For the refusal MDN says "requests that are not accepted should be rejected with a 403 response code." If one URL answers differently depending on these headers, tell the caches: the specification asks developers "to include an appropriate Vary header," which here means Vary: Sec-Fetch-Dest, Sec-Fetch-Site.

Why a header-stripping extension can't touch this

Here's the asymmetry that makes the trouble worth it. X-Frame-Options and frame-ancestors are enforced by the browser, reading a response. Your server sends the page, the header rides along, and the browser refuses to display what it has already received. The refusal lands on the far side of the wire, after delivery, in software you don't own.

That's why an extension can undo it: a rule that deletes x-frame-options and content-security-policy from a response leaves the browser nothing to enforce, and the frame paints. The server never knew. Chrome's declarativeNetRequest rules for response headers are the modern spelling of that trick.

Fetch Metadata gives that rule nothing to work on. The refusal happens on the request, before a response exists. Your server sees Sec-Fetch-Dest: iframe, returns 403, and never sends the page. There's no framing header to strip, because there's no page. Deleting response headers can't reconstitute a body the server never wrote.

Could an extension attack the request side instead? Partly, and the boundary deserves an exact statement. Page script can't — Sec- is a forbidden request-header prefix. An extension is more privileged. Chrome documents that "Before Chrome sends request headers to the server, the headers are updated based on matching modifyHeaders rules," and its set and remove operations apply to request headers too. Whether Chrome refuses Sec--prefixed names to those rules specifically, I couldn't confirm from the documentation, so don't read this as a guarantee against a privileged extension. The specification states a goal, not a promise, on the same point: "Extensions without special privileges for a given site cannot cause requests that would bypass that site's server-side Fetch Metadata logic."

The honest claim is narrow, and still the useful one. Fetch Metadata defeats the header-stripping recipe — the one that ships in real extensions and beats the framing headers — because that recipe edits responses, and this decision gets made on requests. Beating it takes a different act: forging a lie about where the request came from, instead of deleting a restriction from a reply.

Which is why this doesn't replace the framing headers. Send those too. Of the 60 popular apps we measured, 7 allowed framing and 53 refused, and every refusal rests on X-Frame-Options or frame-ancestors — can I embed this site in an iframe? has the results.

Support, and the request that arrives bare

Three of the four are safe to lean on. MDN's baseline note for Sec-Fetch-Dest, -Mode, and -Site: "It's been available across browsers since March 2023." The Sec-Fetch-Dest entry adds a footnote that "Some parts of this feature may have varying levels of support," so don't lean on a rare destination value.

Sec-Fetch-User is the exception. MDN is direct: "This feature is not Baseline because it does not work in some of the most widely-used browsers." Its compatibility data records no Safari support, so don't build anything that needs it.

Two things bring a request with no fetch metadata. An old client is one; plain HTTP is the other, and it surprises people: the specification's algorithm for attaching the headers stops unless the request's URL is a potentially trustworthy URL. Over http://, on a public host, you get nothing.

So LEGACY_DEFAULT above is a real decision, not a placeholder. Fail open, and an attacker just has to arrive without the headers. Fail closed, and you'll refuse browsers that predate the feature. Framing makes it easy: you should be sending X-Frame-Options and frame-ancestors anyway, and they cover exactly the clients that send no fetch metadata.

Questions

Can I drop X-Frame-Options if I check Sec-Fetch-Dest?

No. Anything arriving without the headers — an old browser, a plain-HTTP request — walks past a request-side check. They cover different failure modes, and between them they cost two lines.

Does this stop clickjacking?

It closes the same door from the other side: the frame load is refused before the page exists, which beats refusing to display a page you already sent. It doesn't cover the rest of the attack — see clickjacking explained for the defense ranking.

Why does my frame still load after I added the check?

Log the header values first. The usual causes: a policy keyed on Sec-Fetch-Mode: navigate, which a frame load matches; a check for iframe that misses frame, object, and embed; or a plain-HTTP request, which carries no fetch metadata at all.