Cross-Origin Frame Controls: X-Frame-Options & frame-ancestors

This guide is part of the Web Security Headers Fundamentals reference. It covers the two response headers that govern who is allowed to embed your pages inside an <iframe>, <frame>, <object>, or <embed>: the legacy X-Frame-Options header and its modern, standards-track successor, the Content-Security-Policy frame-ancestors directive. Both exist to neutralize clickjacking. They overlap, they interact, and getting the precedence wrong silently weakens your defense.

Threat Model & Protocol Mechanics

Framing controls exist to stop clickjacking, also called UI redressing. The attack does not exploit a bug in your application — it exploits the fact that a browser will, by default, render any page inside a frame on any other site.

The mechanics are straightforward:

  1. The attacker hosts evil.example and loads your authenticated page (https://bank.example/transfer) inside a transparent iframe.
  2. CSS sets the iframe to opacity: 0 and stacks it above attacker-controlled bait UI with a high z-index.
  3. The attacker positions a tempting decoy (“Claim your prize”) directly under your real “Confirm transfer” button.
  4. The victim — already authenticated to bank.example via ambient cookies — clicks what they believe is the decoy. The click lands on your real button. The action executes with the victim’s credentials.

Because the victim’s session cookies travel with the framed request, no credential theft is required. The browser’s Same-Origin Policy blocks the attacker from reading your framed DOM, but it does not stop them from displaying it and capturing clicks through it. Framing headers close that gap by telling the browser to refuse to render your page as a nested browsing context at all.

The anatomy of the overlay is what makes the attack so effective: two pages are stacked pixel-for-pixel, and only the transparency and stacking order decide which one the victim sees versus which one receives the click.

Transparency is only the simplest technique in a broader family. Cursorjacking replaces the real pointer with a CSS-drawn fake offset by a fixed number of pixels, so the victim aims at one control while the actual click lands elsewhere. Drag-and-drop clickjacking frames your page invisibly and tricks the victim into dragging a token — a CSRF value, an OAuth code visible in a text field — out of the framed document and into an attacker-controlled drop target, exfiltrating data that Same-Origin Policy would otherwise protect. On touch devices the same class of attack is called tapjacking: the framed button receives the tap instead of the decoy. Every one of these variants depends on the browser agreeing to render your page as a nested browsing context, which is exactly what a framing header refuses.

The ancestor check is not limited to the immediate parent. When your page sits inside a chain — attacker frames a trusted intermediary which frames you — a conforming browser walks the entire ancestor chain and rejects the load if any ancestor fails the source list. This closes the “trusted middle frame” bypass, where an attacker embeds a partner you allow and then re-embeds that partner elsewhere. X-Frame-Options: SAMEORIGIN is weaker here: historically several engines compared only the top-level document’s origin rather than every ancestor, so a same-origin outer frame containing a cross-origin middle frame could slip through. frame-ancestors evaluates each hop and has no such gap.

Anatomy of a clickjacking overlayTwo stacked layers show an invisible iframe of your page positioned over an attacker's bait so a single click reaches your real button. victim's click Your real page (in an iframe) opacity: 0 · z-index: 9999 Confirm transfer Attacker bait (what the eye sees) Claim your prize The bait is all the victim sees Your button sits invisibly above it The click passes to your page It runs with live session cookies
The transparent top layer is your authenticated page; the visible bottom layer is the attacker's decoy. A framing header stops the top layer from rendering, collapsing the attack.

Why frame-ancestors supersedes X-Frame-Options

X-Frame-Options was a Microsoft extension first shipped in IE8 (2009) and only later loosely standardized in RFC 7034, which is informational, not a normative standard. It has structural limits: it can express deny all or same origin only, and nothing in between that browsers actually honor. Its attempt at an allowlist (ALLOW-FROM) was never implemented in Chromium or Gecko.

frame-ancestors is part of the normative CSP Level 2/3 specification. It accepts a full source list — multiple origins, schemes, wildcards, and the 'self' and 'none' keywords — making it the only mechanism that can express “embeddable by these specific partners and no one else.” It is the directive every new deployment should lead with.

Browser precedence: which header wins

When a response carries both headers, the CSP specification mandates that frame-ancestors takes precedence and X-Frame-Options is ignored. All current Chromium, Gecko, and WebKit engines implement this. The practical consequence:

Frame controls are evaluated by the browser per navigation, server-enforced — there is no cache analogous to HSTS’s max-age. The header must be present on the framed response every time. They are also only meaningful over a secure transport; pair framing controls with HTTPS enforcement so an attacker cannot strip the header on a plaintext hop.

Three delivery constraints are unique to frame-ancestors and catch teams that treat it like any other CSP directive:

What framing headers do not cover

Framing controls stop your page from being loaded as a nested browsing context. They do not defend against attacks that never frame you. Double-clickjacking — a 2024 technique — opens your page in a new top-level window (a popup or window.open target) rather than an iframe, then exploits the timing gap between mousedown and mouseup while swapping windows underneath a double-click. Because there is no ancestor chain, frame-ancestors and X-Frame-Options are structurally irrelevant to it; mitigations there are application-side (disabling sensitive controls until a deliberate interaction, or requiring a gesture the timing trick cannot forge). Likewise, framing headers do not stop popups, noopener reverse-tabnabbing, or a user who is genuinely phished on a look-alike domain. Treat them as the specific countermeasure to embedding, not a general anti-UI-redressing shield.

Browser decision flow for framing controls A decision tree showing how a browser chooses between frame-ancestors and X-Frame-Options when rendering a page inside an iframe. Page requested inside a frame Has CSP frame-ancestors? yes no Enforce frame-ancestors XFO ignored entirely Has X-Frame-Options? fall back to it Neither present: framing allowed (unsafe) Render or block per source list
The browser checks frame-ancestors first; X-Frame-Options is only consulted when no frame-ancestors directive is present.

Directive Syntax & Spec

X-Frame-Options carries a single token. Header values are matched case-insensitively, but whitespace and unknown tokens are unforgiving — a malformed value causes browsers to treat the header as absent and fall back to permissive framing.

X-Frame-Options: SAMEORIGIN

frame-ancestors is a CSP directive whose value is a source list:

Content-Security-Policy: frame-ancestors 'self' https://partner.example
Directive Type Default Security Impact When to Deviate
X-Frame-Options: DENY XFO token none (framing allowed) Blocks all framing of <iframe>/<frame>/<object>/<embed> from any origin, including your own. Highest XFO posture. Use when the page is never legitimately embedded — login, payment, admin.
X-Frame-Options: SAMEORIGIN XFO token none Permits framing only from an identical scheme + host + port. Mitigates third-party clickjacking while preserving same-origin widgets. Default for app pages that embed themselves (dashboards, internal tools).
X-Frame-Options: ALLOW-FROM uri XFO token (deprecated) n/a Ignored by Chromium and Gecko. Produces no protection and, in some legacy parsers, permissive fallback. Never. Remove it and use frame-ancestors for allowlists.
frame-ancestors 'none' CSP source list n/a Equivalent to DENY; blocks all embedding. Wins over any XFO when both are set. Pages that must never be framed; the modern replacement for DENY.
frame-ancestors 'self' CSP source list n/a Equivalent to SAMEORIGIN. Same-origin embedding only. The modern replacement for SAMEORIGIN.
frame-ancestors 'self' https://a.example https://b.example CSP source list n/a Allowlists specific embedding origins. The capability XFO cannot express. Partner/affiliate embeds, payment widgets, support chat overlays.
frame-ancestors https: CSP scheme-source n/a Permits framing by any origin served over HTTPS. Blocks only plaintext ancestors — a very weak posture that stops almost nothing. Rarely justified; only when embedding is genuinely open but must stay on TLS.
frame-ancestors * CSP wildcard n/a Allows framing by every origin. Functionally equivalent to shipping no header at all. Never as a security posture; occasionally to intentionally override a stricter inherited edge policy for one public embed.
frame-ancestors 'self' https://*.example.com CSP host-source with wildcard n/a Allowlists every subdomain of example.com over HTTPS. The * matches one or more leading labels, not the bare apex. Multi-tenant or many-subdomain products that embed each other.

The gap between the two mechanisms is easiest to read as a capability matrix. Every posture X-Frame-Options can express, frame-ancestors can also express — but not the reverse, which is why new deployments lead with the CSP directive and treat XFO as a fallback only.

Capability comparison of X-Frame-Options and frame-ancestorsA matrix showing that frame-ancestors supports every framing posture X-Frame-Options does plus allowlisting, standardization, and precedence. Capability X-Frame-Options frame-ancestors Block all framing Yes Yes Same-origin only Yes Yes Allowlist one partner No Yes Allowlist many origins No Yes Normative W3C standard No Yes Wins when both are set No Yes
frame-ancestors is a strict superset: it matches every X-Frame-Options posture and adds allowlisting, a normative spec, and precedence when both headers are present.

Malformed-syntax gotchas:

Platform-Specific Implementation

Lead with frame-ancestors. Add X-Frame-Options only as a fallback for pre-2015 user agents. Apply both on every response code so error pages cannot be framed.

Nginx

add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example" always;
add_header X-Frame-Options "SAMEORIGIN" always;

Apache (mod_headers)

Header always set Content-Security-Policy "frame-ancestors 'self' https://partner.example"
Header always set X-Frame-Options "SAMEORIGIN"

IIS (web.config)

<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <add name="Content-Security-Policy" value="frame-ancestors 'self' https://partner.example" />
        <add name="X-Frame-Options" value="SAMEORIGIN" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

Node/Express (Helmet)

const helmet = require('helmet');

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: { frameAncestors: ["'self'", 'https://partner.example'] },
    },
    // helmet sets X-Frame-Options: SAMEORIGIN by default via frameguard
  })
);

Cloudflare (Transform Rules)

Rules → Transform Rules → Modify Response Header → Set static:
  Content-Security-Policy = frame-ancestors 'self' https://partner.example
  X-Frame-Options = SAMEORIGIN

Verification & Diagnostic Workflows

Confirm the header is present, single-valued, and applied across status codes.

# Header presence on a normal response
curl -sI https://your-domain.com | grep -iE 'frame-ancestors|x-frame-options'

# Confirm coverage on a 404 (error pages must not be frameable)
curl -sI https://your-domain.com/this-path-404 | grep -iE 'frame-ancestors|x-frame-options'

# Detect duplicate X-Frame-Options (origin + CDN stacking)
curl -sI https://your-domain.com | grep -ci x-frame-options   # expect 1, not 2

Live embed test. Save the following as frame-test.html, serve it with python3 -m http.server 8080, and open http://localhost:8080/frame-test.html:

<iframe src="https://your-domain.com" style="width:100%;height:400px;"></iframe>

A protected page shows a blank frame and a DevTools Console error such as Refused to display 'https://your-domain.com/' in a frame because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'self'". For an XFO-only page the message references X-Frame-Options to SAMEORIGIN.

CI/CD gate. Fail the pipeline if either header is missing:

hdrs=$(curl -sI https://your-domain.com)
echo "$hdrs" | grep -qi 'frame-ancestors' || echo "$hdrs" | grep -qi 'x-frame-options' \
  || { echo "FAIL: no framing control"; exit 1; }

Troubleshooting, Misconfigurations & Safe Rollback

Header unset Content-Security-Policy
Header always set X-Frame-Options "SAMEORIGIN"

Never roll back to no framing header — that reopens the clickjacking surface entirely.

Frequently Asked Questions

Do I still need X-Frame-Options if I use frame-ancestors? Only for legacy user agents (IE11, Safari before 10) that do not implement frame-ancestors. Modern browsers ignore X-Frame-Options whenever frame-ancestors is present, so keep XFO solely as a fallback and let CSP carry the real policy. See the side-by-side comparison.

Why does my page frame anyway when X-Frame-Options is DENY? A Content-Security-Policy: frame-ancestors directive on the same response overrides X-Frame-Options per the CSP spec. If frame-ancestors is more permissive than your XFO value, the permissive policy wins. Align both headers.

Does X-Frame-Options protect against an iframe reading my page’s data? No. Cross-origin DOM reads are blocked by the Same-Origin Policy regardless. Framing headers stop the page from being displayed and clicked through — the clickjacking vector — not data exfiltration.

Can I allowlist multiple partner domains with X-Frame-Options? No. X-Frame-Options supports only DENY and SAMEORIGIN; its ALLOW-FROM form is unsupported in Chromium and Gecko. Use frame-ancestors 'self' https://a.example https://b.example for any allowlist.

Where should these headers be set — origin or CDN? Either, but exactly one layer, to avoid duplicate headers. The edge (Cloudflare) covers cached responses the origin never serves; the origin guarantees coverage even if the CDN is bypassed. Pick one as authoritative.

Can I set frame-ancestors in a <meta> tag instead of an HTTP header? No. The CSP specification lists frame-ancestors among the directives that are ignored when the policy is delivered through <meta http-equiv>. A meta-tag framing policy is silently discarded. It must be an HTTP response header, which also means it can be applied to non-HTML responses and error pages that never carry a meta tag.

Does frame-ancestors fall back to default-src like other CSP directives? No. frame-ancestors has no fallback to default-src. Setting default-src 'none' does not restrict framing; you must declare frame-ancestors explicitly. This is a deliberate spec choice because framing is an ancestor-context decision, not a resource-fetch decision.

How does the browser handle deeply nested frames? It evaluates the complete ancestor chain against your source list, not just the immediate parent. If your page is framed by B, and B is framed by A, both A and B must satisfy frame-ancestors or the load is refused. This prevents an attacker from laundering an embed through an origin you trust.

Do framing headers stop double-clickjacking? No. Double-clickjacking opens your page in a new top-level window rather than an iframe, so there is no ancestor to check and framing headers do not apply. Defend against it in the application: disable or gate sensitive actions until an unambiguous, deliberate user interaction, rather than relying on response headers.

Should I use frame-ancestors in report-only mode first? Yes, as a discovery step. Content-Security-Policy-Report-Only: frame-ancestors 'self' collects violation reports showing which origins embed you without breaking anything. Once you have confirmed the legitimate embedders, move the directive to the enforcing Content-Security-Policy header. Report-only never blocks, so it is not protection on its own.