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.

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.

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.

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.