X-Frame-Options vs CSP frame-ancestors: Comparison and Migration

This guide sits under the Cross-Origin Frame Controls reference and answers a single question precisely: when do you use X-Frame-Options, when do you use the Content-Security-Policy frame-ancestors directive, and how do you migrate from one to the other without exposing yourself to clickjacking in between.

Direct answer: frame-ancestors is the modern, standards-track replacement for X-Frame-Options. It expresses everything XFO can plus an explicit origin allowlist XFO cannot. When both headers are present, every current browser enforces frame-ancestors and ignores X-Frame-Options. Deploy frame-ancestors as the real policy and keep X-Frame-Options: SAMEORIGIN only as a fallback for pre-2015 user agents.

Capability matrix: X-Frame-Options vs frame-ancestors A matrix comparing X-Frame-Options and CSP frame-ancestors across five capability rows. X-Frame-Options frame-ancestors Deny all framing DENY 'none' Same-origin only SAMEORIGIN 'self' Multi-origin allowlist no yes Wildcard / scheme source no yes Pre-2015 browser support yes no Precedence when both set ignored wins
frame-ancestors covers every XFO capability and adds allowlisting; XFO's only edge is legacy reach.

Configuration Syntax & Exact Values

Map each XFO value to its frame-ancestors equivalent, then add origins XFO could never express:

X-Frame-Options: DENY            →  Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: SAMEORIGIN      →  Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: ALLOW-FROM x    →  Content-Security-Policy: frame-ancestors https://x.example
(no XFO equivalent)              →  Content-Security-Policy: frame-ancestors 'self' https://a.example https://b.example

Annotated directive breakdown:

Consolidate every CSP directive into a single Content-Security-Policy header. Multiple CSP headers are each enforced independently (logical AND), which produces a stricter policy than intended.

Server-Side Configuration

Nginx

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

always emits both headers on 4xx/5xx responses; without it Nginx drops add_header on error pages, leaving them frameable.

Apache (mod_headers)

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

always overrides the default onsuccess table so error responses carry the headers too.

Cloudflare (Transform Rules)

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

Use Set, not Add, so the edge replaces any origin-set header instead of duplicating it.

Node/Helmet

const helmet = require('helmet');

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: { frameAncestors: ["'self'", 'https://partner.example'] },
    },
    // frameguard still emits X-Frame-Options: SAMEORIGIN as the legacy fallback
  })
);

Diagnostic & Verification Steps

curl -sI https://your-domain.com | grep -iE 'content-security-policy|x-frame-options'

Expected output on a correctly migrated page:

content-security-policy: frame-ancestors 'self' https://partner.example
x-frame-options: SAMEORIGIN

Confirm there is exactly one of each header — a count of 2 for either means origin and CDN are both setting it:

curl -sI https://your-domain.com | grep -ci x-frame-options   # expect 1

Then load the page from an unlisted origin in a test iframe and open DevTools Console. A blocked load reports:

Refused to display 'https://your-domain.com/' in a frame because an ancestor
violates the following Content Security Policy directive: "frame-ancestors 'self' https://partner.example".

The message naming frame-ancestors (not X-Frame-Options) confirms the modern directive is the one being enforced.

Edge Cases, Security Implications & Safe Rollback

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

Never roll back to no framing header. For pinpointing which embed broke, see fixing X-Frame-Options blocking legitimate iframes.

Frequently Asked Questions

If frame-ancestors always wins, why keep X-Frame-Options at all? Solely to protect pre-2015 browsers that never implemented frame-ancestors. On every modern engine the CSP directive is authoritative and XFO is ignored. Once your legacy traffic is negligible, drop XFO.

Can I migrate without a report-only phase? You can, but a Content-Security-Policy-Report-Only window of 14-30 days surfaces legitimate embeds you forgot to allowlist before they break in production. It is the lower-risk path for any page that is embedded by partners.

Does combining DENY and frame-ancestors ‘self’ block everything? No. The frame-ancestors 'self' directive wins on modern browsers, so the page remains embeddable on its own origin despite the DENY. Align the two values or the result surprises you.

Conclusion

Roll out incrementally: set frame-ancestors in report-only mode on staging, review violation reports to confirm no legitimate embed is caught, promote to an enforcing Content-Security-Policy in production, and keep X-Frame-Options: SAMEORIGIN as a legacy fallback until traffic analytics justify removing it. The migration is low-risk when each origin you legitimately allow is verified before enforcement.