DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Securing the Digital Frontline: Advanced Cybersecurity Strategies for Modern Web Development
  • Cloud Security in Hybrid and Multi-Cloud
  • Why Your Terraform Drift Alerts Are Useless (And How to Fix Them)
  • When Guest Access Becomes an Attack Surface: A Technical Analysis of the City-Forum Campaign

Trending

  • Containerizing LLMs: Best Practices for Docker-Based AI Workloads
  • The Code-Volume Delusion: Rethinking Engineering Velocity in the AI Era
  • How AI Is Actually Changing SRE Tools, Part 2: ITOps, Chaos Engineering, and the Rest of the Job
  • Stop Hand-Rolling Chat UIs: Streaming LLM Tokens Into React Native Without the Jank
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Making User-Generated Sites Embeddable: X-Frame-Options vs CSP Frame-Ancestors

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.

By 
Ruslan Ianberdin user avatar
Ruslan Ianberdin
·
Aug. 28, 26 · Tutorial
Likes (0)
Comment
Save
Tweet
Share
180 Views

Join the DZone community and get the full member experience.

Join For Free

If 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-Options is the legacy control. It has three meaningful states: DENY, SAMEORIGIN, and the deprecated, widely-ignored ALLOW-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 returns X-Frame-Options: SAMEORIGIN, a third-party site can never frame you, full stop.
  • CSP frame-ancestors is the modern replacement. It is part of Content-Security-Policy and takes a real source list: frame-ancestors 'none', 'self', https://example.com, or *. It is granular where X-Frame-Options is 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, including X-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 without X-Frame-Options. Whether such a page can be framed is then governed by the frame-ancestors the 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.

YAML
 
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:

YAML
 
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:

  1. 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.
  2. Sandbox the frame. The embedding side should use <iframe sandbox="allow-scripts allow-popups ..."> and grant only the capabilities the content needs. Omit allow-same-origin where you can, so the framed document runs with an opaque origin.
  3. 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-ancestors should win over the proxy default. Author intent beats infrastructure default.
  4. 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-Options cannot express "allow these origins" - use CSP frame-ancestors for anything granular, and drop X-Frame-Options entirely 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 sandbox attribute, and letting the page author's policy win over the edge default.
Cryptographic Service Provider Frame (networking) security

Opinions expressed by DZone contributors are their own.

Related

  • Securing the Digital Frontline: Advanced Cybersecurity Strategies for Modern Web Development
  • Cloud Security in Hybrid and Multi-Cloud
  • Why Your Terraform Drift Alerts Are Useless (And How to Fix Them)
  • When Guest Access Becomes an Attack Surface: A Technical Analysis of the City-Forum Campaign

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook