Skip to content
StackSiren

X-Frame-Options vs CSP frame-ancestors: which should I use?

By StackSiren · Published

Use the Content-Security-Policy frame-ancestors directive as your modern clickjacking control, and keep sending X-Frame-Options as a simple fallback for older browsers. For most apps that should not be embedded, that means frame-ancestors 'none' plus X-Frame-Options: DENY (or 'self' / SAMEORIGIN if you embed your own pages).

Neither header encrypts traffic or stops XSS. They only answer: “who is allowed to put this page inside a frame?” Full header pack: security headers guide. Host recipes: Vercel, Netlify, HSTS explained, CSP for React + Vite.

What clickjacking is

Clickjacking (UI redressing) loads your page in a transparent or obscured iframe on an attacker's site, then tricks the user into clicking your real buttons — “Delete account”, “Confirm payment”, “Connect GitHub” — while they think they are clicking something else.

Defence is twofold:

  1. Don't allow unexpected parents to embed you — that is X-Frame-Options / frame-ancestors.
  2. For sensitive actions, require a fresh user gesture or re-auth so a single framed click is not enough.

This post is about the embedding headers. They are cheap, HTTP-only controls every small SaaS should set deliberately.

The two headers

X-Frame-Options

MDN documents three historical values; only two matter in modern browsers:

X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN
  • DENY — no embedding, not even by your own origin.
  • SAMEORIGIN — only same-origin ancestors may embed the page.
  • ALLOW-FROM uri — obsolete. Modern browsers that see it often ignore the whole header. Do not use it; use frame-ancestors instead.

Important: setting X-Frame-Options in a <meta http-equiv> tag has no effect. It must be an HTTP response header.

CSP frame-ancestors

Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self';
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com;
  • 'none' — similar intent to X-Frame-Options: DENY.
  • 'self' — similar intent to SAMEORIGIN.
  • Explicit origins — allow specific embedding parents (docs sites, partner portals, your marketing domain).

Notes from MDN that trip people up:

  • frame-ancestors does not fall back to default-src. A policy of default-src 'none' still allows anyone to embed you unless you also set frame-ancestors.
  • It is a navigation directive and is not supported inside a <meta> CSP — send it as an HTTP header.
  • It checks each ancestor. Nested iframes need every ancestor to match.
  • It is different from frame-src, which controls which URLs your page may load in its own iframes.

Which should you use?

Goal Recommended
App must never be framed Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY
Only your own origin may frame you frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN
A named partner may embed you frame-ancestors 'self' https://partner.example.com (XFO alone cannot express this cleanly — skip obsolete ALLOW-FROM)
You actively want to be embeddable by anyone Omit both (rare for dashboards; sometimes OK for public widgets)

Why both? frame-ancestors is the flexible, modern control (CSP Level 2+, widely available since ~2018). X-Frame-Options remains useful for older clients and for scanners that still look for it specifically. When both are present, browsers that understand CSP enforce frame-ancestors; older ones fall back to XFO. Keep the values aligned so you do not accidentally allow in one what you deny in the other.

Setting them on common hosts

Confirm what you already send:

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

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Frame-Options", "value": "DENY" },
        {
          "key": "Content-Security-Policy",
          "value": "frame-ancestors 'none';"
        }
      ]
    }
  ]
}

If you already ship a fuller CSP (script-src, etc.), merge frame-ancestors into that single policy string rather than sending two competing Content-Security-Policy headers unless you understand how browsers combine them. Walkthrough: add security headers on Vercel and CSP for React + Vite.

Netlify / Bolt exports (_headers or netlify.toml)

# _headers
/*
  X-Frame-Options: DENY
  Content-Security-Policy: frame-ancestors 'none'

Details: Netlify security headers.

Next to HSTS and the rest

Clickjacking headers sit beside Strict-Transport-Security, X-Content-Type-Options: nosniff, Referrer-Policy, and a careful CSP. HSTS alone does not stop framing: What is HSTS?. StackSiren's free first scan flags missing security headers on sites you own; the security headers checker is the focused tool for this cluster.

Embeds you actually want

If a partner must iframe your status page or a public widget:

  1. Prefer a dedicated embed route (/embed/...) with a looser frame-ancestors allowlist, and keep the main app on 'none' / DENY.
  2. List every allowed parent origin explicitly — no wildcards like * in frame-ancestors for sensitive apps.
  3. Strip cookies or use a cookieless embed session where possible so a framed page cannot act as the logged-in user without extra checks.
  4. Re-test with curl and a real iframe from the partner origin after every CSP edit.

For admin dashboards, billing, and anything with “delete” or “pay” buttons, default to no embedding.

How browsers pick a winner

When a response includes both headers:

  1. Browsers that implement CSP Level 2+ enforce frame-ancestors for framing decisions.
  2. Older clients that never learned frame-ancestors still honour X-Frame-Options.
  3. If you set contradictory values (for example XFO DENY but frame-ancestors https://evil.example), modern browsers follow CSP and older ones follow XFO — a messy split. Keep them aligned.

There is no benefit to omitting XFO “because CSP exists” on a public marketing site that still sees ancient WebViews. The header is two tokens long.

Rollout without locking yourself out

  1. Inventory every place you deliberately iframe yourself today (admin inside admin, status page, Storybook, payment widget docs).
  2. Ship SAMEORIGIN / frame-ancestors 'self' first if you need same-origin frames; move to DENY / 'none' once you confirm nothing breaks.
  3. Use a preview deploy or staging hostname for the first CSP edit — a typo in a large CSP string can break scripts even when frame-ancestors is fine.
  4. After production ships, re-run curl -sI on apex and www, and load the app inside a throwaway HTML file on another origin to confirm the frame is blocked.
<!-- save as /tmp/frame-test.html and open as a file or other origin -->
<iframe src="https://yourapp.com/" width="800" height="600"></iframe>

You should see an empty frame or a browser console error about framing, not your logged-in dashboard.

What these headers do not cover

  • XSS — a script running on your origin can still do damage; that is CSP script-src / sanitisation, not framing.
  • CSRF — framing defence is not a CSRF token. State-changing POSTs still need SameSite cookies, tokens, or both.
  • Open redirects / OAuth confusion — separate checks.
  • API abuse — JSON endpoints are not “framed”; protect them with auth and rate limits.

Use framing headers as one line in the wider pack from the security headers guide, not as the whole security story.

FAQ

Is X-Frame-Options enough on its own in 2026? It blocks simple cases, but it cannot express an allowlist of third-party parents. Prefer frame-ancestors and keep XFO as a fallback.

Can I set these in a meta tag? No for X-Frame-Options, and frame-ancestors is also not applied from a meta CSP. Use HTTP headers from your host or server.

What if DENY breaks my own docs site iframe? Serve the embeddable page on a separate path with a tailored frame-ancestors list, or switch that path to 'self' / SAMEORIGIN only.

Does frame-ancestors stop someone loading my API in a script tag? No. It only controls embedding as a document frame. APIs still need auth, CORS allowlists and rate limits.

Will StackSiren see a missing frame-ancestors? The free passive scan flags missing security headers broadly. After you deploy, re-check with curl -I and the security headers checker on a site you own.

Check your app now

Free passive scan, results in under a minute.

Run a free scan

Fix guides for this topic

Platform checklists: Vercel · Bolt

Related posts

Sources