Allow Specific Origins to Frame Your Site
This guide is part of the Cross-Origin Frame Controls: X-Frame-Options & frame-ancestors reference. It covers the one framing scenario the legacy header physically cannot express: letting a small, named set of partner origins embed your pages in an <iframe> while blocking every other site. The only mechanism that works in current browsers is the Content-Security-Policy frame-ancestors directive with an explicit source list. The dead X-Frame-Options: ALLOW-FROM syntax must be removed, not adapted.
Configuration Syntax & Exact Values
The task is an allowlist: your own origin plus one or more trusted https partner origins may frame the page; everyone else is refused. frame-ancestors takes a source list, so you enumerate exactly those origins and nothing else.
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com https://portal.vendor.net
Every token in that list is load-bearing. Read it left to right:
frame-ancestors— the directive. It governs which origins may embed this response as a nested browsing context (iframe,frame,object,embed). It is unrelated toframe-src, which governs what your page may load.'self'— the single-quoted keyword for your own origin (scheme + host + port). Without it, your own pages cannot frame each other. Quotes are mandatory;selfunquoted is parsed as a hostname and silently fails to match your origin.https://partner.example.com— a fully qualified partner origin. The scheme is explicit and must behttps. A bare hostpartner.example.commatches bothhttpandhttps, widening the allowlist beyond what you intend.https://portal.vendor.net— a second partner, space-separated. Add as many as needed on the same directive.
There is no comma anywhere. Sources are separated by single spaces; a stray comma starts a new directive under CSP grammar and corrupts the policy.
What you must NOT do
X-Frame-Options cannot express a multi-origin allowlist. Its ALLOW-FROM uri form was a Microsoft extension that shipped only in IE and old Edge, was never implemented in Chromium or Gecko, and is now removed everywhere. A header like X-Frame-Options: ALLOW-FROM https://partner.example.com is treated as an invalid value: modern browsers ignore the whole header, meaning the page becomes framable by anyone, the exact opposite of the intent. Delete it.
# DEAD — never ships. Browsers ignore this and the page ends up framable by all.
X-Frame-Options: ALLOW-FROM https://partner.example.com
If you keep an X-Frame-Options header at all during migration, the only defensible value is SAMEORIGIN as a fallback for pre-2015 engines — and note that SAMEORIGIN blocks your named partners on those old browsers. There is no XFO value that permits a foreign origin. This is why the allowlist scenario is frame-ancestors-only. See the X-Frame-Options vs CSP frame-ancestors comparison for the full precedence contract.
Server-Side Configuration
Ship the directive on the framed response every time — framing is enforced per navigation, with no client-side cache. If you already send a broader CSP for scripts and styles, frame-ancestors must live on that same header line, not a second Content-Security-Policy header. Ordering matters: see the edge cases below.
Nginx
add_header is silently dropped in any location that later sets its own headers, and it does not fire on internally generated error responses unless flagged. Use always so the policy is present on 4xx/5xx bodies that an attacker might frame too.
# In the server or location block. 'always' emits the header on error responses as well.
add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example.com https://portal.vendor.net" always;
# Remove any legacy allowlist attempt; do not send X-Frame-Options ALLOW-FROM.
proxy_hide_header X-Frame-Options;
If you use the third-party headers-more module, more_set_headers replaces rather than appends and applies to all status codes by default, which avoids the duplicate-CSP trap:
more_set_headers "Content-Security-Policy: frame-ancestors 'self' https://partner.example.com https://portal.vendor.net";
more_clear_headers "X-Frame-Options";
Apache
Header always set guarantees the header on error documents; the default set (without always) skips them. unset strips any inherited XFO allowlist.
# httpd.conf, VirtualHost, or .htaccess with mod_headers enabled.
Header always set Content-Security-Policy "frame-ancestors 'self' https://partner.example.com https://portal.vendor.net"
Header always unset X-Frame-Options
See the Apache hardening guide for merging this with an existing CSP.
Cloudflare (Transform Rule)
A Cloudflare Response Header Transform Rule sets the value at the edge. Set the CSP header and remove any origin-sent X-Frame-Options.
{
"action": "rewrite",
"action_parameters": {
"headers": {
"Content-Security-Policy": {
"operation": "set",
"value": "frame-ancestors 'self' https://partner.example.com https://portal.vendor.net"
},
"X-Frame-Options": { "operation": "remove" }
}
}
}
Express / Helmet
Configure the directive through Helmet, and disable Helmet’s default X-Frame-Options so it does not fight your CSP.
const helmet = require("helmet");
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
"frame-ancestors": ["'self'", "https://partner.example.com", "https://portal.vendor.net"],
},
},
frameguard: false, // stop Helmet from emitting X-Frame-Options: SAMEORIGIN
})
);
Django
In Django, the built-in XFrameOptionsMiddleware cannot express an allowlist, so set the CSP header directly and leave XFO off. django-csp 4.x uses the CONTENT_SECURITY_POLICY setting:
# settings.py — django-csp 4.x. Emit frame-ancestors and do not enable XFrameOptionsMiddleware.
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"frame-ancestors": ["'self'", "https://partner.example.com", "https://portal.vendor.net"],
},
}
Diagnostic & Verification Steps
Confirm the wire bytes first, then confirm real browser framing behavior.
1. Inspect the response header with curl. -s silences the progress meter, -I issues a HEAD so you see only headers:
curl -sI https://your-site.example/embed-page | grep -i -E 'content-security-policy|x-frame-options'
Expected output — a single CSP line naming your allowlist and no X-Frame-Options:
content-security-policy: frame-ancestors 'self' https://partner.example.com https://portal.vendor.net
If two content-security-policy lines appear, the browser intersects them and the stricter one may drop your partners. Collapse them onto one directive set. If x-frame-options still appears, your removal directive did not run.
2. Verify the allowed partner can frame you. Serve a one-line test page from the partner origin and load it:
<!-- hosted at https://partner.example.com/frametest.html -->
<iframe src="https://your-site.example/embed-page" width="600" height="400"></iframe>
The iframe renders normally. In DevTools, the Network panel shows the framed request completing with 200 and no CSP violation on the console.
3. Verify a non-allowlisted origin is refused. Load the same iframe from any origin not in the list. The frame stays blank and the browser console logs a refusal that explicitly cites the directive:
Refused to frame 'https://your-site.example/' because an ancestor
violates the following Content Security Policy directive: "frame-ancestors 'self'
https://partner.example.com https://portal.vendor.net".
The presence of the directive text in the error is the definitive signal that frame-ancestors — not a leftover XFO header — is doing the enforcing.
Edge Cases, Security Implications & Safe Rollback
Trap 1 — an existing broad CSP silently overrides you. If your app already sends Content-Security-Policy: default-src 'self' from application code and your web server adds a second Content-Security-Policy header with frame-ancestors, the browser enforces both policies independently. A policy without frame-ancestors does not block framing, but if the first policy happens to include its own frame-ancestors 'none', that stricter one wins and your partners are blocked. Frame-ancestors is not additive across headers in your favor — the intersection applies. Emit a single, merged Content-Security-Policy header that contains every directive including frame-ancestors.
Trap 2 — origin, not path. frame-ancestors matches by origin (scheme, host, port). You cannot allow https://partner.example.com/app/ while blocking https://partner.example.com/marketing/; listing the origin authorizes any page on it to frame you. If a partner host runs untrusted user content on the same origin, that content can frame you too. Where a partner cannot isolate to a dedicated origin, framing is the wrong integration; use a signed, purpose-built embed instead.
Trap 3 — subdomains and ports are exact. https://partner.example.com does not match https://app.partner.example.com or https://partner.example.com:8443. Use a host wildcard https://*.partner.example.com only if you genuinely trust every subdomain; a wildcard covers hosts you have not vetted. Enumerate exact origins whenever the partner list is finite.
Safe rollback. Ship frame-ancestors in report-only mode first so a mistake cannot break a partner integration:
Content-Security-Policy-Report-Only: frame-ancestors 'self' https://partner.example.com https://portal.vendor.net; report-to csp-endpoint
Report-only evaluates the policy and emits violation reports without blocking any frame. Watch the reports for legitimate partner origins you missed, add them, then promote the identical directive to the enforcing Content-Security-Policy header. To roll back instantly, revert to frame-ancestors 'self' (partners lose embedding, your site stays protected) rather than removing the header, which would reopen framing to everyone.
Conclusion
Stage the allowlist in a non-production environment and confirm each partner origin renders and each stranger is refused. Move to production behind Content-Security-Policy-Report-Only for one to two weeks, mining violation reports for partner origins you forgot. When the reports are clean, promote the byte-identical frame-ancestors directive to an enforcing Content-Security-Policy header on a single line and delete every trace of X-Frame-Options: ALLOW-FROM.
Frequently Asked Questions
Why can’t I just use X-Frame-Options: ALLOW-FROM for one partner?
Because no shipping browser implements it. ALLOW-FROM was an IE/old-Edge-only extension, removed from Chromium and never present in Gecko or WebKit. Sending it produces an invalid header that modern browsers ignore, leaving your page framable by everyone. frame-ancestors is the only mechanism that allowlists foreign origins.
How do I allow more than one partner origin?
List each origin space-separated on the same frame-ancestors directive: frame-ancestors 'self' https://a.example https://b.example. There are no commas — a comma starts a new CSP directive and corrupts the policy. Add 'self' so your own pages can still frame each other.
Does frame-ancestors match a specific path on the partner site?
No. Matching is by origin (scheme, host, port) only. Listing https://partner.example.com authorizes every page on that origin to embed you. If the partner serves untrusted content on the same origin, that content is authorized too; require a dedicated origin or use a signed embed instead.
Will listing https://partner.example.com also allow its subdomains?
No. Origin matching is exact, so app.partner.example.com and partner.example.com:8443 are not covered. Add each exact origin, or use a host wildcard like https://*.partner.example.com only when you trust every subdomain under it.
Do I still need an X-Frame-Options header for old browsers?
Only SAMEORIGIN as a fallback, and it will block your named partners on pre-2015 engines that lack frame-ancestors — there is no XFO value that permits a foreign origin. If those partners must work on legacy browsers, framing is not viable there; otherwise drop XFO entirely once legacy traffic is negligible.