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:
- The attacker hosts
evil.exampleand loads your authenticated page (https://bank.example/transfer) inside a transparent iframe. - CSS sets the iframe to
opacity: 0and stacks it above attacker-controlled bait UI with a highz-index. - The attacker positions a tempting decoy (“Claim your prize”) directly under your real “Confirm transfer” button.
- The victim — already authenticated to
bank.examplevia 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:
- A page with
X-Frame-Options: DENYandContent-Security-Policy: frame-ancestors 'self'is embeddable on its own origin — the CSP directive wins, the stricter XFO is discarded. - This is not a bug; it is the defined contract. If you deploy both, make them agree, or accept that
frame-ancestorsis the effective policy.
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.
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:
- Wrapping origins in quotes (
frame-ancestors "https://a.example") is invalid; only the keywords'self','none'take quotes. - A trailing or duplicated
X-Frame-Optionsheader (origin + CDN both setting it) is RFC-noncompliant and browsers may treat the page as having no policy. frame-ancestorsdoes not accept the'unsafe-inline'/'nonce-...'tokens — only host-source and scheme-source expressions plus'self'/'none'.- A path in a
frame-ancestorssource (https://a.example/embed) is ignored for ancestor matching; framing is decided at origin granularity.
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;
- Security impact:
alwaysemits the headers on 4xx/5xx responses too — without it, Nginx dropsadd_headeron error pages, leaving them frameable. - Verification:
curl -sI https://your-domain.com | grep -iE 'frame-ancestors|x-frame-options'
Apache (mod_headers)
Header always set Content-Security-Policy "frame-ancestors 'self' https://partner.example"
Header always set X-Frame-Options "SAMEORIGIN"
- Security impact:
alwaysapplies the header across all HTTP status codes; the defaultonsuccesstable skips errors. Requiresmod_headersloaded. - Verification:
apachectl -M 2>/dev/null | grep headers_module && curl -sI https://your-domain.com | grep -i frame
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>
- Security impact:
customHeadersinjects at the site/application level inside the IIS pipeline, before ASP.NET processing, covering static and dynamic responses uniformly. - Verification:
(Invoke-WebRequest -Uri https://your-domain.com).Headers['Content-Security-Policy']
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
})
);
- Security impact: Middleware runs before route handlers and emits both headers on every response Helmet touches. Mount it before your routers so error responses inherit it.
- Verification:
curl -sI http://localhost:3000 | grep -iE 'frame-ancestors|x-frame-options'
Cloudflare (Transform Rules)
Rules → Transform Rules → Modify Response Header → Set static:
Content-Security-Policy = frame-ancestors 'self' https://partner.example
X-Frame-Options = SAMEORIGIN
- Security impact: Edge enforcement applies before traffic reaches the origin and covers cache hits the origin never sees. Use Set (not Add) to avoid stacking a second header on top of an origin-set one.
- Verification:
curl -sI https://your-domain.com | grep -iE 'frame-ancestors|x-frame-options|cf-cache-status'
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
- Symptom: a legitimate iframe (partner portal, support chat) stopped rendering after deploy. → The embedding origin is not in your source list. Add it:
frame-ancestors 'self' https://that-partner.example. Detailed walkthrough in fixing X-Frame-Options blocking legitimate iframes. - Symptom:
ALLOW-FROMpartner embed silently broke in Chrome/Firefox. →ALLOW-FROMwas never supported there and provides no protection. Replace it entirely withframe-ancestors 'self' https://partner.example. - Symptom: page frameable despite XFO set. → A
frame-ancestorsdirective is also present and overriding it. Reconcile the two so they express the same policy;frame-ancestorsis authoritative. - Symptom: duplicate
X-Frame-Optionsheader. → Both origin and CDN are setting it. Pick one layer. On Cloudflare use Set, not Add; on the origin remove the directive if the edge owns it. - Symptom: header missing on 404/500 only. → The
alwaysflag (Nginx/Apache) is missing. Add it. - Safe rollback: if framing controls break production embedding and you cannot immediately identify the missing origin, drop to the least-restrictive still-protective policy rather than removing the header. Replace
frame-ancestors 'self'with an explicit allowlist of the broken origins, or temporarily fall back toX-Frame-Options: SAMEORIGINalone:
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.