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
| Value | Effect | Status |
|---|---|---|
DENY | No page may frame it, including your own | Works everywhere |
SAMEORIGIN | Only pages on the same origin (scheme, host and port) may frame it | Works everywhere |
ALLOW-FROM https://a.example | Was meant to allow one origin | Obsolete. Ignored by modern browsers, so the header does nothing |
Conflicting values, e.g. DENY, SAMEORIGIN | Treated as DENY | Usually a sign two layers both set the header |
| Unrecognised values or typos | Ignored, so the page stays frameable | Check 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 expression | Allows |
|---|---|
'none' | Nobody. Use it on its own. |
'self' | Pages on the same origin as the framed page |
https://partner.example | That exact origin, on the default port 443 |
https://*.partner.example | Any subdomain of partner.example, but not partner.example itself |
https://partner.example:8443 | That 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-Policycontainsframe-ancestors, modern browsers ignore X-Frame-Options completely. - Otherwise X-Frame-Options applies. A
frame-ancestorssent only inContent-Security-Policy-Report-Onlydoesn'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 sent | Result in current browsers |
|---|---|
X-Frame-Options: DENY + frame-ancestors https://partner.example | partner.example can frame it; X-Frame-Options is ignored |
X-Frame-Options: ALLOW-FROM https://partner.example only | Any 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.
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.exampleis unambiguous. A barepartner.exampleinherits the scheme of your page, which is easy to get wrong. - A missing port means the default for the scheme, so
https://partner.exampledoesn'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.examplematchesapp.partner.exampleand deeper names likeeu.app.partner.example, but not the barepartner.example. List both if you need both.- The wildcard only works as the leftmost label.
https://partner.*andhttps://app-*.partner.examplearen't valid. https:allows every HTTPS site and*allows every site. Neither is an allowlist.
'self' nuances
- Keywords need single quotes. Unquoted
selfornoneis read as a hostname and won't do what you want. 'self'is the origin of the framed page, exactly.example.com,www.example.comandapp.example.comare three different origins. If your marketing site athttps://example.comframeshttps://app.example.com, the app must listhttps://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-ancestorsis 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
| Goal | Content-Security-Policy | X-Frame-Options |
|---|---|---|
| Block all framing | frame-ancestors 'none' | DENY |
| Same origin only | frame-ancestors 'self' | SAMEORIGIN |
| Specific partners | frame-ancestors 'self' https://partner.example | Omit, or SAMEORIGIN as a legacy fallback |
| Allow everyone | frame-ancestors *, or omit | Omit |
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.
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.