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

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.

Decision tree mapping framing requirement to frame-ancestors value One question, who may frame this page, resolves to one of four frame-ancestors values, each annotated with its old X-Frame-Options equivalent. Who may frame this page? Nobody 'none' was DENY Only this site 'self' was SAMEORIGIN A named partner 'self' partner.com no XFO equivalent All subdomains *.partner.com never bare *
The framing requirement maps to exactly one frame-ancestors value; only the named-partner case has no X-Frame-Options form, which is why migration is mandatory.

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.

Sequence of a browser evaluating frame-ancestors on a framed document The partner page requests the framed document, the server returns the CSP, and the browser compares the ancestor origin to the allowlist before rendering. Partner page Browser Your server loads <iframe src=your page> GET / for framed document 200 OK + frame-ancestors ancestor = partner.com in allowlist? yes → iframe renders first mismatch → Refused to frame
The browser fetches the framed document, reads frame-ancestors from the response headers, then matches every ancestor origin before painting the frame.

Edge Cases, Security Implications & Safe Rollback

  1. Do not leave both headers with conflicting policies. Serving X-Frame-Options: SAMEORIGIN alongside frame-ancestors 'self' https://trusted-partner.com means current browsers allow the partner (they prefer frame-ancestors) while some security scanners and legacy clients still block it on the X-Frame-Options value. The result is intermittent, hard-to-diagnose breakage. Remove X-Frame-Options completely.
  2. 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 like https://*.partner.com cover subdomains but never use a bare *, which lets any site frame you and reopens clickjacking.
  3. 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 a location block (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.
  4. 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.com will not match an embedder served over http:// or on a non-default port like partner.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.

Before and after: XFO block vs frame-ancestors allowlist Before, X-Frame-Options SAMEORIGIN blocks the trusted partner's iframe; after, CSP frame-ancestors allowlists the partner so the embed loads. Before: X-Frame-Options partner.com iframe your page SAMEORIGIN Refused to display: blocked After: frame-ancestors partner.com iframe your page allowlisted Embed loads The fix Remove: X-Frame-Options: SAMEORIGIN Add: frame-ancestors 'self' https://trusted-partner.com
X-Frame-Options cannot name an external embedder, so it blocks the partner. frame-ancestors allowlists the exact origin and the embed loads.

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.