I built a small tool on kraft called “Can you iframe it?” — type a URL, load it in a real <iframe>, see if it renders or gets silently blocked. It started as a one-off check for a specific integration, but the failure modes it surfaces are the same four every time. Worth writing down once instead of re-discovering them per project.
X-Frame-Options vs. CSP frame-ancestors
X-Frame-Options is the old header, and it’s blunt: DENY, SAMEORIGIN, or a single allowed origin. If you need more than one origin allowed to frame a page, it can’t express that.
The CSP directive frame-ancestors replaced it and does allowlisting properly:
Content-Security-Policy: frame-ancestors 'self' https://partner-app.example.com;If a page you control refuses to be framed and you want it embeddable, this is almost always the fix — drop X-Frame-Options and allowlist the embedding origin(s) in CSP instead. Browsers respect CSP over the legacy header when both are present, so there’s no need to strip the old one everywhere, but it’s one less place to keep in sync.
Cookies need SameSite=None; Secure
Even once framing itself is allowed, sessions can look broken inside the iframe specifically. The usual cause: the session cookie is set with SameSite=Strict or SameSite=Lax. Both are silently dropped by the browser in a cross-origin iframe context — the request goes out, the cookie just never attaches.
The fix is SameSite=None; Secure on any cookie that needs to survive inside a cross-origin frame. Secure is mandatory for SameSite=None — browsers reject the combination without it, so this only works over HTTPS. Local HTTP dev needs a fallback to Lax.
This one is easy to misdiagnose, because everything looks fine outside the iframe — same app, same login, same cookie. It only breaks in the framed context, which is exactly the case people forget to test.
Some identity providers refuse to be framed, period
Headers and cookies fix most things, but a subset of IdPs (Microsoft’s login endpoints among them) set X-Frame-Options: DENY on their own hosted login pages, non-negotiably, as an anti-clickjacking measure. No allowlist, no override — that page will never render inside anyone’s iframe.
The workaround isn’t a header change, it’s a different flow:
- Detect you’re in an iframe (
window.self !== window.top). - On login, open the IdP in a popup (
window.open) instead of navigating the iframe to it. - The IdP redirects the popup through the auth flow as normal.
- The popup’s landing page sends the result back via
postMessagetowindow.opener, then closes itself. - The parent (still showing the iframe) receives the message, stores the session, and reloads.
Everything downstream — session cookie, auth state — works exactly like a normal login. The only difference is the login screen itself lives in a popup for the few seconds it’s needed, because that’s the one piece that can’t be framed.
WebView is not an iframe
If the same content also needs to load inside a native mobile WebView (as opposed to a web iframe), none of the above applies — WebView has no X-Frame-Options restriction at all. But it has its own gotchas:
- Keep the WebView instance alive across show/hide cycles. Destroying and recreating it drops the session cookie, so the user re-logs-in every single time the panel reopens.
- Some IdPs block WebView user-agents outright (Google and Microsoft both do this for their hosted login pages, returning something like
disallowed_useragent). The fix is to detect the login URL and hand it off toSFSafariViewController(iOS) or Chrome Custom Tabs (Android) instead of loading it in the WebView directly.
The shape of it
None of these four are hard individually, but they fail in ways that look identical from the outside — a blank frame, or a login loop — so it’s worth checking them in this order: is the page blocked from framing at all (headers), does the session survive inside the frame (cookies), does the login page itself allow framing (popup pattern), and is this actually a WebView, not a browser iframe (different rules entirely).