Fix: X-Frame-Options Blocking Legitimate Iframes
This guide fixes the case where a trusted partner needs to embed your page in an iframe but X-Frame-Options refuses to display it. The cause is structural: X-Frame-Options has no syntax for allowlisting a specific external origin. The fix is to drop it and express framing rules with the frame-ancestors directive of Content-Security-Policy. The header’s full threat model lives in the cross-origin frame controls reference; here the focus is the exact replacement and how to verify it.
Configuration Syntax & Exact Values
X-Frame-Options accepts only three forms — DENY, SAMEORIGIN, and the dead ALLOW-FROM — none of which can name an arbitrary trusted partner. DENY blocks all framing; SAMEORIGIN allows only your own origin; ALLOW-FROM uri was removed from Chromium and Firefox years ago and is ignored. There is no way to say “allow https://trusted-partner.com and nobody else.” That capability only exists in CSP.
The fix — remove X-Frame-Options and serve:
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com
frame-ancestors— controls which origins may embed this document in a<frame>,<iframe>,<object>, or<embed>. It is the modern, allowlist-capable replacement forX-Frame-Options.'self'— allows your own origin to frame the page (theSAMEORIGINequivalent). The single quotes are required.https://trusted-partner.com— an explicit allowlisted embedder. Add more space-separated origins as needed (https://a.example https://b.example). Use'none'to replicateDENY.
Unlike X-Frame-Options, frame-ancestors is evaluated by all current browsers and supports multiple origins, wildcards (https://*.partner.com), and scheme/port matching. When both headers are present, browsers honor frame-ancestors — but conflicting signals confuse audits and older clients, so remove X-Frame-Options entirely.
The value you serve follows directly from one question: who is allowed to frame this document? Map the answer to a directive value rather than guessing. Each X-Frame-Options value has an exact frame-ancestors equivalent, and only the allowlist case has no X-Frame-Options form at all — which is precisely why the partner embed fails until you migrate.
Server-Side Configuration
The change has two parts on every platform: delete the X-Frame-Options line and add (or extend an existing CSP with) frame-ancestors. If you already serve a CSP, append frame-ancestors to that one header rather than emitting a second policy.
Nginx
# Remove this line entirely:
# add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-partner.com" always;
always forces emission on 4xx/5xx responses; without it the header is dropped from error pages. An add_header in a location block replaces inherited headers, so if any location resets headers, repeat the CSP line there and ensure no stray X-Frame-Options survives.
Apache
# Remove: Header always set X-Frame-Options "SAMEORIGIN"
Header always unset X-Frame-Options
Header always set Content-Security-Policy "frame-ancestors 'self' https://trusted-partner.com"
Header always unset strips any inherited X-Frame-Options from a parent scope so it cannot conflict; always set emits the CSP even on internally generated error documents. Requires mod_headers.
Cloudflare
In Rules → Transform Rules → Modify Response Header: add a rule to Remove header X-Frame-Options, and a second to Set static Content-Security-Policy to frame-ancestors 'self' https://trusted-partner.com. If a Cloudflare managed feature or origin still injects X-Frame-Options, the remove rule strips it at the edge so only the CSP reaches the browser.
Node/Helmet
const helmet = require('helmet');
app.use(helmet({
frameguard: false, // disables Helmet's X-Frame-Options
contentSecurityPolicy: {
useDefaults: false,
directives: { 'frame-ancestors': ["'self'", 'https://trusted-partner.com'] }
}
}));
Setting frameguard: false is essential — Helmet emits X-Frame-Options: SAMEORIGIN by default, which would re-introduce the very block you are removing. Register Helmet before route and error handlers.
Diagnostic & Verification Steps
The symptom appears in the embedding partner’s DevTools Console:
Refused to display 'https://yourdomain.com/' in a frame because it set
'X-Frame-Options' to 'sameorigin'.
or, if a stale CSP is involved:
Refused to frame 'https://yourdomain.com/' because an ancestor violates
the Content Security Policy directive: "frame-ancestors 'self'".
Confirm the old header is gone and the new one is present:
curl -sI https://yourdomain.com | grep -iE 'x-frame-options|content-security-policy'
Expected output after the fix:
content-security-policy: frame-ancestors 'self' https://trusted-partner.com
There must be no x-frame-options line. If one still appears, a CDN, app layer, or location block is re-adding it. Because curl shows only what your origin sends, run it against every layer in turn — the origin directly, then through the CDN hostname — to find which hop injects the stale header. A header present at the origin but absent through the CDN (or vice versa) tells you exactly where to edit.
Reproduce the cross-origin check locally rather than waiting on the partner. frame-ancestors compares origins, so localhost framing localhost is same-origin and will not exercise the allowlist. Serve your page on one host and the embedding shell on a genuinely different origin — for example 127.0.0.1 versus localhost, or two loopback ports fronted by distinct hostnames in /etc/hosts. Load a minimal <iframe src="https://yourdomain.com/"> from that second origin and watch the Console: a clean render confirms the allowlist entry matches, while Refused to frame prints the effective policy the browser applied, which you can diff against the value you intended to ship.
Verify the embed loads. Open the partner’s page that hosts the iframe; the document should render. In DevTools → Network → the framed document → Response Headers, confirm the CSP value and the absence of X-Frame-Options. A blocked load still shows the Refused to display/Refused to frame console error pinpointing which header caused it.
Understanding when the browser applies the check explains why the header, not the markup, is what matters. The embedder’s <iframe> tag never sees your policy; the browser fetches the framed document, reads the response headers, then walks the chain of ancestor browsing contexts and compares each ancestor’s origin against frame-ancestors. If every ancestor matches, the frame paints; the first mismatch aborts the load with the Refused to frame error. This is also why a frame-ancestors directive placed in a <meta http-equiv> tag is silently ignored — the specification only honors frame-ancestors when delivered as a real HTTP response header, so a policy that works via curl but not via a page-level meta tag is behaving correctly, not broken.
Edge Cases, Security Implications & Safe Rollback
- Do not leave both headers with conflicting policies. Serving
X-Frame-Options: SAMEORIGINalongsideframe-ancestors 'self' https://trusted-partner.commeans current browsers allow the partner (they preferframe-ancestors) while some security scanners and legacy clients still block it on theX-Frame-Optionsvalue. The result is intermittent, hard-to-diagnose breakage. RemoveX-Frame-Optionscompletely. - Dashboards, embeds, and payment iframes. Each legitimate embedder must be listed explicitly. A status dashboard embedded by
https://status.partner.com, an analytics widget, or a hosted payment iframe from a PSP each need their exact origin in the allowlist. Wildcards likehttps://*.partner.comcover subdomains but never use a bare*, which lets any site frame you and reopens clickjacking. - Per-path policy. Often only one route should be embeddable (an embeddable widget) while the rest of the app stays
frame-ancestors 'none'. Scope the permissive policy to that path with alocationblock (Nginx) or<Location>/.htaccess(Apache), and keep the strict default everywhere else, so you are not exposing the whole origin to framing for one feature. - Scheme and port are matched, not ignored. A source expression without a scheme inherits the protected document’s scheme, but an explicit one is enforced verbatim:
https://partner.comwill not match an embedder served overhttp://or on a non-default port likepartner.com:8443. If the partner embeds from a staging host on a custom port, list it exactly (https://staging.partner.com:8443). This strictness is a feature — it stops an attacker who controls a plaintext or odd-port variant of a listed host — but it is a common cause of a fix that works in production yet fails in a partner’s staging environment.
Rollback directive. This change is non-destructive and not cached as a persistent policy. To revert, restore the previous header value and redeploy; the next response applies immediately with no client-side persistence to clear. The full comparison of when to use each mechanism is covered in X-Frame-Options vs CSP frame-ancestors.
Frequently Asked Questions
Why can’t X-Frame-Options just allow my partner’s domain?
Its grammar has no origin-allowlist form. DENY and SAMEORIGIN are the only working values; the origin-specific ALLOW-FROM was removed from Chromium and Firefox and is ignored. Allowlisting an external embedder is only possible with CSP frame-ancestors.
Should I keep X-Frame-Options for old browsers?
No, when it conflicts with your intent. If both are served, modern browsers honor frame-ancestors while legacy clients may still block the partner on X-Frame-Options, producing inconsistent behavior. Remove X-Frame-Options and rely on frame-ancestors, which all current browsers support.
Can I allow more than one embedder?
Yes. List each origin space-separated: frame-ancestors 'self' https://a.example https://b.example. Subdomain wildcards (https://*.partner.com) work; a bare * does not — it permits any site and reopens clickjacking.
How do I only allow framing on one page?
Scope the permissive frame-ancestors policy to that route with an Nginx location block or Apache <Location>/.htaccess, and keep frame-ancestors 'none' as the default everywhere else.
Conclusion
Roll the change out by first adding frame-ancestors 'self' https://trusted-partner.com in staging, confirming the partner’s embed renders, then removing X-Frame-Options so no conflicting signal remains. Promote to production and verify with curl -sI that only the CSP header is present. Because the policy is not cached client-side, you can scope it per path or revert it with a single immediate deploy if an embedder list changes.