A partner says your page refuses to render inside their <iframe>. Which two HTTP response headers control whether a document may be framed, and how does X-Frame-Options differ from the Content-Security-Policy frame-ancestors directive?
answer
- framing is opt-out, not blocked
- legacy header is all-or-nothing
- source list beats DENY or SAMEORIGIN
- every ancestor is checked
- meta tag delivery is ignored
basics
~10 sTwo response headers decide it: the legacy X-Frame-Options, which only offers DENY or SAMEORIGIN, and the Content-Security-Policy directive frame-ancestors, which takes a source list. Where both appear, browsers honour frame-ancestors and ignore X-Frame-Options.
solid answer
~40 sFraming is opt-out: nothing stops a page from putting your URL in an `<iframe>`, so the framed document has to refuse in its own response headers. The old mechanism is `X-Frame-Options`, which is all-or-nothing — `DENY` or `SAMEORIGIN`. Its `ALLOW-FROM` value never shipped in Chromium or WebKit, so it cannot express "only this partner". The current mechanism is CSP: `Content-Security-Policy: frame-ancestors 'none' | 'self' | https://partner.example`, which takes a real source list and is checked against every ancestor document in the chain, not just the immediate parent. Two gotchas: `frame-ancestors` is ignored when delivered in a `<meta http-equiv>` tag, so it must be a genuine header; and when both headers are present, browsers that support `frame-ancestors` ignore `X-Frame-Options` entirely — so a permissive CSP silently overrides a strict `DENY`.
code
bash · 1 linecurl -sI https://app.example/report | grep -iE 'x-frame-options|content-security-policy'go deeper
Know both header names and be able to say that the framed page, not the embedder, decides. Recall that X-Frame-Options offers only DENY and SAMEORIGIN, while frame-ancestors takes a list of allowed origins.
Explain the mechanics: the whole ancestor chain is checked, meta-delivered policies do not enforce this directive, and frame-ancestors takes precedence when both headers are present rather than the two being combined.
Show you can debug it from a live response — read the console refusal, inspect headers through the CDN, and separate a genuine framing refusal from a blank frame caused by third-party cookie behaviour. Know that a permissive CSP silently overrides a legacy DENY.
Own the policy: a deny-by-default posture with narrow, route-scoped carve-outs for named partners, plus a rule that allowlisting an origin is a trust decision reviewed like any other integration. Decide whether legacy header support is still worth the drift risk.
## What the browser is actually deciding Before a browser renders a document inside a frame, it asks whether that document consents to being embedded by the documents above it. Nothing else stops the embed: the same-origin policy governs *access* between documents, not *inclusion*, so any page on the web may put your URL in an `<iframe>` and render it. Framing is therefore opt-out, and the opt-out must come from the framed response itself — the embedder is the untrusted party here and cannot be asked to behave. The practical reason to care is that a framed page still runs with its own cookies and session. An embedder that cannot read the frame's DOM can still position it, make it transparent, and overlay bait so that a click lands on a real control inside your page. ## X-Frame-Options: the legacy switch `X-Frame-Options` predates any specification effort and offers exactly two usable values: ``` X-Frame-Options: DENY X-Frame-Options: SAMEORIGIN ``` `DENY` refuses all framing. `SAMEORIGIN` allows it only from your own origin. A third value, `ALLOW-FROM <origin>`, appeared in old Firefox and Internet Explorer but was never implemented in Chromium or WebKit, and is now obsolete — treat it as nonexistent. Consequently `X-Frame-Options` cannot express "my origin plus one named partner". It must be sent as a response header. A `<meta http-equiv="X-Frame-Options">` element does nothing. ## CSP frame-ancestors: the current mechanism ``` Content-Security-Policy: frame-ancestors 'none' Content-Security-Policy: frame-ancestors 'self' https://partner.example https://*.partner.example ``` The directive takes a source list, so scheme, host, port and host wildcards are all expressible. `'none'` corresponds to `DENY` and `'self'` to `SAMEORIGIN`, with everything in between now available. Two behavioural details matter more than the syntax: **It checks the whole ancestor chain.** If your page sits inside a frame on `b.example`, which is itself framed by `a.example`, then both `b.example` and `a.example` must match your source list. That closes the wrapping trick where an allowed partner is itself embedded by an attacker. **It is ignored in a meta tag.** `frame-ancestors`, along with `report-uri` and `sandbox`, has no effect when the policy is delivered via `<meta http-equiv="Content-Security-Policy">`. The browser has already committed to loading the document by the time it parses markup, so the decision has to live in the response headers. A policy that looks correct in the HTML source and does nothing in production is almost always this mistake. ## Precedence when both are sent Browsers that support `frame-ancestors` ignore `X-Frame-Options` when both are present. This is not a "most restrictive wins" merge. If a page sends `X-Frame-Options: DENY` alongside `Content-Security-Policy: frame-ancestors https://partner.example`, the partner embed succeeds. Teams that add a permissive CSP for one integration and leave a strict legacy header in place as a supposed backstop have, in fact, loosened the policy everywhere. Keeping `X-Frame-Options` around is still reasonable as a floor for very old user agents, but it must be kept *consistent* with the CSP, not treated as an independent guard. ## Diagnosing the partner's report Open the partner page, look at the console — browsers report a refusal to display in a frame and name the offending directive or header — and then inspect the framed URL's response headers directly: ``` curl -sI https://app.example/report | grep -iE 'x-frame-options|content-security-policy' ``` If the header is present, the fix is to add the partner origin to `frame-ancestors` (and to remove or align any `X-Frame-Options`). If it is absent, the block is coming from somewhere else — a CDN or reverse proxy injecting a default, or a different mechanism such as a cookie that is not sent in a third-party context, which produces a blank frame rather than a refusal. ## What these headers do not do They control only whether your document may be *embedded*. They say nothing about what the framed page may do once loaded, and nothing about non-document resources — an image or script of yours can still be included by any page unless you send `Cross-Origin-Resource-Policy`. They also do not apply to top-level navigation: a link to your page always works. Finally, they are enforced by the browser, so a server-side fetch of the same URL is unaffected; if the content itself must not leave your origin, authorisation, not a framing header, is the control.
- Why does frame-ancestors do nothing when the policy is delivered in a meta tag?The framing decision is made before the document is parsed — the browser has to accept or refuse the embed as the response arrives. By the time a `<meta http-equiv>` element is seen, the document is already being rendered inside the frame. For the same reason `report-uri` and `sandbox` are also ignored in meta-delivered policies, so any framing control has to be a real response header.
- If a partner is allowlisted in frame-ancestors, what else should you check before calling the integration safe?That the allowlisted origin is one you actually trust, since frame-ancestors grants it real UI over a session-authenticated page. Also confirm the page has no clickable state-changing actions worth stealing, that it does not accept `postMessage` from arbitrary origins, and that the allowlist is scoped to the specific routes the partner needs rather than applied site-wide.
- Both headers are absent from a response, yet the frame renders blank in a partner's page. What else could explain it?Most often cookies: in a third-party context a session cookie without `SameSite=None; Secure` is not sent, so the framed page renders a logged-out or error state rather than being refused. Other candidates are a CSP on the embedder restricting `frame-src`, a redirect chain that loses the path, or the page's own script detecting `window.top !== window.self` and bailing out.
saying these in an interview costs you the question
- Claiming X-Frame-Options ALLOW-FROM works in current browsers
- Putting frame-ancestors in a meta tag and expecting enforcement
- Assuming the stricter of the two headers wins
- Thinking the same-origin policy already prevents framing
- Believing SAMEORIGIN only checks the immediate parent today