Making User-Generated Sites Embeddable: X-Frame-Options vs CSP Frame-Ancestors
A blank “refused to connect” iframe is usually a legacy X-Frame-Options header. Here is how to make user content embeddable with CSP frame-ancestors and scoped headers.
Join the DZone community and get the full member experience.
Join For FreeIf you let users publish something, such as a page, prototype, or dashboard, sooner or later you want an "embed this" button so they can drop it into a blog, a portfolio, or docs, the way a CodePen result embeds. Then you ship the iframe, and it renders a blank box: refused to connect.
The reflex is to blame the iframe. It's almost never the iframe. It's a response header.
The Two Headers That Decide Whether You Can Be Framed
There are two mechanisms, and they are not equivalent:
X-Frame-Optionsis the legacy control. It has three meaningful states:DENY,SAMEORIGIN, and the deprecated, widely-ignoredALLOW-FROM. Crucially, there is no value that means "allow any origin" or "allow this list of origins." It is deny / same-origin / nothing-useful. If your edge returnsX-Frame-Options: SAMEORIGIN, a third-party site can never frame you, full stop.- CSP
frame-ancestorsis the modern replacement. It is part ofContent-Security-Policyand takes a real source list:frame-ancestors 'none','self',https://example.com, or*. It is granular whereX-Frame-Optionsis binary.
The catch that trips people up: if you send both, X-Frame-Options is still honored by many browsers and will block framing regardless of how permissive your frame-ancestors is. So to actually be embeddable by third parties, you have to remove X-Frame-Options, not just add a permissive frame-ancestors next to it.
The Footgun: One Global Security-Headers Middleware
Here is the trap. The application that rendered our published sites already made the right call in code: it disabled frameguard and emitted a permissive frame-ancestors. And yet every embed was blank.
The header was not coming from the app. It was re-added at the edge. A single shared "secure-headers" middleware, the kind every reverse proxy ships and every security checklist tells you to apply globally - included X-Frame-Options: SAMEORIGIN in its response headers. The proxy ran that middleware on the router that served published user sites, stamping SAMEORIGIN on top of the app's deliberate "please frame me" headers. The edge won.
State it plainly: applying one blanket security-headers policy to every route is a footgun the moment one of those routes is supposed to serve embeddable content. That middleware is correct for your API and your authenticated app. It is wrong for the one route whose entire job is to be put inside someone else's <iframe>.
The Fix: Scope Headers Per Trust Zone
The fix is not "turn off security headers." It is to stop treating every route as one trust zone:
- Authenticated and sensitive routes (
/api, realtime/WebSocket, the editor app) keep the full secure-headers set, includingX-Frame-Options: SAMEORIGIN. Those should never be framed; clickjacking protection stays. - The route that serves published, public, client-only user pages gets a near-identical header set - same
X-Content-Type-Options,Referrer-Policy,Strict-Transport-Security- but withoutX-Frame-Options. Whether such a page can be framed is then governed by theframe-ancestorsthe page itself serves.
In practice, that is a second middleware that is a copy of the first minus one header, pointed only at the published-pages router. Surgical. Nothing else loses protection.
secure-headers: # sensitive routes - keeps clickjacking protection
headers:
customResponseHeaders:
X-Frame-Options: "SAMEORIGIN"
contentTypeNosniff: true
referrerPolicy: "strict-origin-when-cross-origin"
stsSeconds: 31536000
pages-headers: # same set, minus X-Frame-Options - embeddable pages only
headers:
contentTypeNosniff: true
referrerPolicy: "strict-origin-when-cross-origin"
stsSeconds: 31536000
Then the page that is meant to be embeddable expresses its own policy:
Content-Security-Policy: frame-ancestors *;
(or a specific allowlist, if only certain hosts should embed it).
Embedding User-Generated Content Safely
"Make it embeddable" and "make it safe" have to hold at the same time, because you are putting code you did not write into a frame. A few rules that travel well:
- Isolate every project on its own origin. Serve each published site from its own subdomain (
{slug}.example.io), never a shared path. Origin isolation means one project's script cannot reach another's storage, cookies, or DOM. This is the single biggest lever. - Sandbox the frame. The embedding side should use
<iframe sandbox="allow-scripts allow-popups ...">and grant only the capabilities the content needs. Omitallow-same-originwhere you can, so the framed document runs with an opaque origin. - Let the page opt out. A published page should be able to override the edge default and refuse framing - its own
X-Frame-Options/frame-ancestorsshould win over the proxy default. Author intent beats infrastructure default. - Keep authenticated surfaces un-framable. The embeddable posture applies to public content only. Anything behind a login keeps
SAMEORIGIN.
This is the posture we landed on at Playcode, an AI website and app builder: published projects each live on their own origin, the published-pages route drops X-Frame-Options so a one-line embed drops a live project into any blog or docs page, while the editor, API, and Playcode Cloud backend keep full clickjacking protection. A static published page carries the same minimal framing risk that previews and custom domains already had. The difference is that it is now a deliberate, scoped decision instead of an inconsistent accident across routes.
Takeaways
- A blank "refused to connect" embed is almost always
X-Frame-Options, not your iframe. X-Frame-Optionscannot express "allow these origins" - use CSPframe-ancestorsfor anything granular, and dropX-Frame-Optionsentirely on routes that must be embeddable.- Do not apply one global security-headers middleware to routes that serve embeddable content; scope headers per trust zone.
- Embeddability and safety coexist through origin isolation, the iframe
sandboxattribute, and letting the page author's policy win over the edge default.
Opinions expressed by DZone contributors are their own.
Comments