testiframe.com

X-Frame-Options vs CSP frame-ancestors: Allow or Block Iframe Embedding

Two response headers decide whether other sites can put your pages in an iframe. This guide covers how each one works, which wins when you send both, and ready-to-paste configs for every common server.

Published · 8 min read
Quick answer

X-Frame-Options is the legacy header with two working values, DENY and SAMEORIGIN. CSP frame-ancestors replaces it: it accepts 'none', 'self' and a list of allowed origins, and when present it overrides X-Frame-Options. To allow specific domains, use frame-ancestors. To block framing, send both headers.

What is X-Frame-Options?

X-Frame-Options is an HTTP response header that tells the browser whether a page may be rendered inside a <frame>, <iframe>, <embed> or <object> on another page. It predates CSP and every browser in use supports it.

X-Frame-Options: SAMEORIGIN
ValueEffectStatus
DENYNo page may frame it, including your ownWorks everywhere
SAMEORIGINOnly pages on the same origin (scheme, host and port) may frame itWorks everywhere
ALLOW-FROM https://a.exampleWas meant to allow one originObsolete. Ignored by modern browsers, so the header does nothing
Conflicting values, e.g. DENY, SAMEORIGINTreated as DENYUsually a sign two layers both set the header
Unrecognised values or typosIgnored, so the page stays frameableCheck spelling

The biggest limitation is obvious from the table: there is no working way to say "allow this one partner". That gap is what frame-ancestors fills.

What is CSP frame-ancestors?

frame-ancestors is a Content Security Policy directive that lists which pages may embed yours. It is sent in the Content-Security-Policy header, alone or alongside your other directives:

Content-Security-Policy: frame-ancestors 'self' https://partner.example https://*.partner.example
Source expressionAllows
'none'Nobody. Use it on its own.
'self'Pages on the same origin as the framed page
https://partner.exampleThat exact origin, on the default port 443
https://*.partner.exampleAny subdomain of partner.example, but not partner.example itself
https://partner.example:8443That host on port 8443 only (:* means any port)
https:Any page served over HTTPS
*Any http or https page

Unlike most CSP directives, frame-ancestors does not fall back to default-src. A policy of default-src 'self' says nothing about who may frame you.

Which header wins when you send both?

  • If an enforced Content-Security-Policy contains frame-ancestors, modern browsers ignore X-Frame-Options completely.
  • Otherwise X-Frame-Options applies. A frame-ancestors sent only in Content-Security-Policy-Report-Only doesn't count, so X-Frame-Options stays in force.
  • If you send several CSP headers, each policy is enforced and the frame must pass all of them. The strictest wins.
  • Browsers that predate CSP Level 2, such as Internet Explorer, only understand X-Frame-Options. That's why sending both is still the recommended practice.
Headers sentResult in current browsers
X-Frame-Options: DENY + frame-ancestors https://partner.examplepartner.example can frame it; X-Frame-Options is ignored
X-Frame-Options: ALLOW-FROM https://partner.example onlyAny site can frame it
X-Frame-Options: SAMEORIGIN + report-only frame-ancestors *Same origin only
Two CSP headers: frame-ancestors * and frame-ancestors 'self'Same origin only

Meta tags don't work

Neither control works from HTML. Browsers ignore <meta http-equiv="X-Frame-Options">, and a CSP delivered through <meta http-equiv="Content-Security-Policy"> ignores frame-ancestors while still applying its other directives. By the time a meta tag is parsed, the page is already rendering in the frame. If your host can't set response headers, put a CDN or proxy in front that can.

See which framing header a site sends

Paste a URL to see its X-Frame-Options and frame-ancestors values, which one the browser applies, and whether the page can be framed.

Why X-Frame-Options ALLOW-FROM stopped working

ALLOW-FROM was meant to let one named origin frame the page. Chrome never supported it, Firefox dropped it in version 70, and no current browser honours it. It only ever took a single origin, too.

The dangerous part: because the value isn't recognised, the whole header is ignored. A page that sends only X-Frame-Options: ALLOW-FROM https://partner.example can be framed by any site, which is the opposite of what the author intended. Replace it with frame-ancestors:

# Before: ignored by modern browsers
X-Frame-Options: ALLOW-FROM https://partner.example

# After
Content-Security-Policy: frame-ancestors 'self' https://partner.example

If you want legacy browsers to fail closed, you can also send X-Frame-Options: SAMEORIGIN. Modern browsers skip it because frame-ancestors is present; very old ones fall back to same-origin only.

How frame-ancestors matches origins

Schemes, ports and paths

  • Always write the scheme. https://partner.example is unambiguous. A bare partner.example inherits the scheme of your page, which is easy to get wrong.
  • A missing port means the default for the scheme, so https://partner.example doesn't cover a staging server on :8443. List it, or use :*.
  • Leave paths off. Ancestors are compared as origins, so a path adds nothing and may not match the way you expect.

Wildcards

  • https://*.partner.example matches app.partner.example and deeper names like eu.app.partner.example, but not the bare partner.example. List both if you need both.
  • The wildcard only works as the leftmost label. https://partner.* and https://app-*.partner.example aren't valid.
  • https: allows every HTTPS site and * allows every site. Neither is an allowlist.

'self' nuances

  • Keywords need single quotes. Unquoted self or none is read as a hostname and won't do what you want.
  • 'self' is the origin of the framed page, exactly. example.com, www.example.com and app.example.com are three different origins. If your marketing site at https://example.com frames https://app.example.com, the app must list https://example.com; 'self' won't cover it.

The full ancestor chain

frame-ancestors checks every ancestor up to the top window, not just the direct parent. If partner.example embeds a wrapper on widgets.partner-cdn.example, which then embeds your page, your policy must allow both:

Content-Security-Policy: frame-ancestors https://partner.example https://widgets.partner-cdn.example

This is what breaks previews in CMS editors and page builders that nest your page inside extra frames. The HTML standard applies SAMEORIGIN to every ancestor as well, so a same-origin page inside a third-party frame is blocked too.

How to test with Content-Security-Policy-Report-Only

Before you lock framing down, find out who frames you today. A report-only policy never blocks anything; it reports what would have been blocked:

Content-Security-Policy-Report-Only: frame-ancestors 'self' https://partner.example; report-uri https://example.com/csp-reports; report-to csp
Reporting-Endpoints: csp="https://example.com/csp-reports"
  • frame-ancestors is not enforced from a report-only header, so this can't break an embed.
  • It also doesn't switch off X-Frame-Options. Your existing header keeps protecting you while you collect reports.
  • Reporting support for this directive varies between browsers, so treat the reports as a sample rather than a complete list.

When the reports only show origins you expect, move the same value into the enforced Content-Security-Policy header.

Recipes: block, same-origin, partners, everyone

GoalContent-Security-PolicyX-Frame-Options
Block all framingframe-ancestors 'none'DENY
Same origin onlyframe-ancestors 'self'SAMEORIGIN
Specific partnersframe-ancestors 'self' https://partner.exampleOmit, or SAMEORIGIN as a legacy fallback
Allow everyoneframe-ancestors *, or omitOmit

Blocking is the right default for login, account, admin and payment pages; the clickjacking protection guide explains why. If you allow everyone, remember that any site can then overlay your UI, and embedded sessions depend on third-party cookie rules.

Framework defaults add X-Frame-Options

Helmet and Rails send SAMEORIGIN, Django and Spring Security send DENY, and WordPress sends SAMEORIGIN on login and admin pages. If you "allow everyone" by sending nothing, that default still blocks. Disable it, or send an explicit frame-ancestors so browsers ignore it.

How to set the headers on each server

Each snippet uses the partner recipe. Swap in the value you need from the table above. If you already send a CSP, add frame-ancestors to that header rather than adding a second one.

Nginx

server {
    add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example" always;

    # Block all instead:
    # add_header Content-Security-Policy "frame-ancestors 'none'" always;
    # add_header X-Frame-Options "DENY" always;

    # Drop an X-Frame-Options header sent by the app behind proxy_pass
    proxy_hide_header X-Frame-Options;
}

An add_header in a location block discards every add_header inherited from server, so repeat the header there if needed.

Apache

# .htaccess or <VirtualHost>, requires mod_headers
Header unset X-Frame-Options
Header always unset X-Frame-Options
Header always set Content-Security-Policy "frame-ancestors 'self' https://partner.example"

Express with Helmet

const helmet = require("helmet");

app.use(helmet({
  contentSecurityPolicy: {
    directives: { frameAncestors: ["'self'", "https://partner.example"] },
  },
  xFrameOptions: false, // "frameguard" in older Helmet versions
}));

// Block all instead:
// directives: { frameAncestors: ["'none'"] }, xFrameOptions: { action: "deny" }

Next.js

// next.config.js
module.exports = {
  async headers() {
    return [{
      source: "/:path*",
      headers: [
        { key: "Content-Security-Policy", value: "frame-ancestors 'self' https://partner.example" },
      ],
    }];
  },
};

headers() doesn't apply to static exports (output: "export"); set the header at your host instead.

Cloudflare Pages and Netlify (_headers)

# _headers in your build output folder
/*
  Content-Security-Policy: frame-ancestors 'self' https://partner.example

On Cloudflare Pages, _headers rules don't apply to responses from Pages Functions, so set the header in the function for those routes.

Vercel

{
  "headers": [{
    "source": "/(.*)",
    "headers": [
      { "key": "Content-Security-Policy", "value": "frame-ancestors 'self' https://partner.example" }
    ]
  }]
}

IIS (web.config)

<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <remove name="X-Frame-Options" />
        <add name="Content-Security-Policy" value="frame-ancestors 'self' https://partner.example" />
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

How to verify your framing headers

First confirm what the server actually sends, after redirects and any CDN in between:

curl -sL -D - -o /dev/null https://example.com | grep -iE '^(x-frame-options|content-security-policy)'

Then test the real behaviour. Paste your URL into the testiframe.com iframe tester and it reports which header applies. Enter a partner's origin under "Would it load on your site?" and the tester evaluates your policy against that origin, which is the only way to check an allowlist without deploying a test page on the partner's domain. Try an origin that should be blocked as well, to prove the policy isn't wider than you meant.

If a partner still sees a blank frame, work through the refused to connect checklist: their own CSP frame-src, mixed content and login redirects can all block a frame your headers allow. Once framing works, postMessage is the safe way for the two pages to talk.

FAQ

What is the difference between X-Frame-Options and frame-ancestors?

X-Frame-Options is the older header and only supports DENY and SAMEORIGIN. CSP frame-ancestors accepts 'none', 'self', schemes and a list of origins with wildcards. When an enforced CSP includes frame-ancestors, browsers ignore X-Frame-Options.

How do I allow an iframe from a specific domain?

Send the HTTP header Content-Security-Policy: frame-ancestors 'self' https://partner.example, listing every origin that should embed you. Remove X-Frame-Options or keep SAMEORIGIN as a legacy fallback. X-Frame-Options ALLOW-FROM won't work.

Is X-Frame-Options ALLOW-FROM deprecated?

Yes, it's obsolete. Chrome never supported it and Firefox removed it in version 70. Modern browsers ignore the whole header when it uses ALLOW-FROM, so the page can be framed by anyone. Use frame-ancestors instead.

Should I send both X-Frame-Options and frame-ancestors?

Yes, when blocking: frame-ancestors 'none' with DENY, or 'self' with SAMEORIGIN. Modern browsers use frame-ancestors and very old ones fall back to X-Frame-Options. For partner allowlists, frame-ancestors does the real work.

Can I set frame-ancestors or X-Frame-Options in a meta tag?

No. Browsers ignore frame-ancestors in a meta CSP and ignore X-Frame-Options meta tags entirely. Both must be sent as HTTP response headers.

Does frame-ancestors fall back to default-src?

No. frame-ancestors is one of the few CSP directives with no fallback, so default-src 'self' does not restrict framing. Add frame-ancestors explicitly.