TulipLot works best in Chrome or a Chromium-based browser. Some features may be limited here.
← All posts
Embedding · · 9 min read

frame-ancestors: the directive that decides who may frame you

Checked on 2026-08-24.

X-Frame-Options gave sites a switch with two positions: nobody, or only me. frame-ancestors gave them a guest list. It's a directive inside a Content Security Policy rather than a header of its own, it's specified in CSP Level 2 and Level 3, and where both exist it's the one the browser actually obeys.

The guest list is the whole point. A site can name the partner that's allowed to embed its dashboard, or its own desktop app's custom URL scheme, or a wildcard covering its staging subdomains, and refuse everything else. That flexibility is also where it goes wrong, because a source list has four or five ways to look correct and match nothing.

The syntax

The value is either the single token 'none' or a space-separated list of source expressions. The quotes on 'none' are not decoration; the specification requires them, and frame-ancestors none without them is a hostname, not a keyword.

For this directive the useful source expressions are:

  • 'none' — no frame, from anywhere. The closest equivalent to X-Frame-Options: DENY.
  • 'self' — the page's own origin, exactly. Not "my company", not "my domain": the same scheme, host, and port, with a narrow allowance for an upgrade from http to https. www.example.com and example.com are two origins, and 'self' on one does not cover the other.
  • A scheme, written with its colon, like https:. Any origin on that scheme matches.
  • A host, like https://partner.example.com, optionally with a port.
  • A wildcard host, like *.example.com, matching subdomains.

Unlike most CSP directives, this one governs more than <iframe>. MDN lists <frame>, <iframe>, <object>, and <embed> — the whole family of elements that can put one document inside another.

Here's what real lists look like. Every value below came from a request we made on 2026-08-24:

Site frame-ancestors
GitHub 'none'
Fastmail 'self'
Sentry *.sentry.io
Proton Mail https://calendar.proton.me https://drive.proton.me
Notion 'self' https://app.notion.com notion://app.notion.com notion://www.notion.so https://app.contentful.com

Notion's is the one to study, because every entry earns its place — and because reading it correctly means knowing which host sent it. We requested https://www.notion.so, and the response carrying this policy came back from https://www.notion.com, after a redirect. So 'self' here means www.notion.com framing itself, not the .so address we typed. https://app.notion.com is a second web origin on a different host, which 'self' would not have covered. The two notion:// entries look like the desktop application, which loads pages through its own URL scheme — and note that www.notion.so appears in this list only there, under the custom scheme, never as an HTTPS origin. https://app.contentful.com is a third party: a content management system whose editors evidently need Notion pages in a frame. Five entries, four distinct reasons, and one host that isn't the one we asked for.

Proton Mail's is interesting for the opposite reason: the list names Proton Calendar and Proton Drive, and does not include 'self'. Read literally, Proton Mail declines to frame itself while allowing its two sibling apps to do it.

It beats X-Frame-Options

When a response carries both, the directive wins. This isn't a browser quirk or a race between headers; it's written into the specification. CSP Level 3 states that "the frame-ancestors directive overrides the X-Frame-Options header," and that when an enforced policy contains the directive, the header "will be ignored." The HTML Standard's algorithm implements exactly that: it checks the policy list first, and returns "allowed" without ever looking at X-Frame-Options as soon as it finds an enforced frame-ancestors.

The word enforced is doing real work in that sentence. A policy delivered in Content-Security-Policy-Report-Only has a different disposition, and the algorithm skips it. A report-only frame-ancestors therefore neither blocks a frame nor suppresses your X-Frame-Options — which is exactly what you want while you're testing, and a trap if you thought report-only mode was live.

The practical consequence is that the header in your terminal output may not be the one deciding anything. Two measured examples make it concrete:

  • Evernote sends X-Frame-Options: DENY together with frame-ancestors https://docs.google.com https://www.evernote.com https://evernote.com. Those two instructions flatly contradict each other. A current browser obeys the list and ignores the DENY, so Google Docs is permitted to frame Evernote and your page is not.
  • Fastmail sends X-Frame-Options: DENY with frame-ancestors 'self'. Same shape, smaller list: the DENY would have stopped Fastmail framing itself, and the directive overrides it and allows exactly that.

Neither site is misconfigured. Both are sending a legacy header for old clients and a real policy for everything else. You just have to read them in the right order. X-Frame-Options explained covers the legacy half.

One honest caveat about the measurements, since it's the same trap: our own Gmail row shows an X-Frame-Options value and no frame-ancestors at all, because an unauthenticated request never reaches the mailbox. It lands on Google's sign-in page, and that's whose headers we captured. Why Gmail and Google Calendar never load in an iframe follows the whole redirect chain.

You cannot set it in a meta tag

Most CSP directives can be delivered in a <meta http-equiv="Content-Security-Policy"> element. This one can't. The specification names the exceptions in a single note: report-only policies aren't supported in a <meta> element, and "neither are the report-uri, frame-ancestors, and sandbox directives."

The reason is the same timing problem that rules out a meta-tag X-Frame-Options: the browser has to decide when the response arrives, and a <meta> element doesn't exist until the document is already being parsed.

So if your hosting lets you edit HTML but not response headers, you cannot restrict framing at all. There's no HTML-only version of this control. That's worth knowing before you spend an evening on it.

Four ways to write a list that matches nothing

A missing scheme isn't a wildcard. frame-ancestors example.com does not mean "example.com on any scheme". When a source has no scheme, the browser resolves it against the scheme of the page sending the policy, with one allowance: the specification lets a schemeless source on an http page match an https ancestor, because that direction is an upgrade. So on an HTTPS site, a bare host matches HTTPS ancestors only. That's usually what you wanted, but it is not what the syntax appears to say, and it bites the moment a local http:// build tries to frame a production page.

A path is not part of the match you think it is. The ancestor is compared as an origin, so the only path there is /. A trailing slash — https://partner.example.com/ — is harmless and matches. Anything more specific, like https://partner.example.com/dashboard, never matches an ancestor at all, because the ancestor's path is always /. You cannot allow one page of a partner site and refuse the rest. The unit of trust here is the origin.

A missing port means the default port only. Omit the port and the source matches only ancestors on the default port for the scheme — 443 for HTTPS. https://app.example.com will not match https://app.example.com:8443, and staging environments often run on non-default ports. Name the port, or use :* to accept any. Two measured lists show sites handling this deliberately: LinkedIn's includes *.www.linkedin.com:*, taking any port, and X's names both https://x.com and https://x.com:443, spelling out the default rather than trusting it.

A wildcard doesn't cover the bare domain. *.example.com matches any host ending in .example.com, at any depth, so a.b.example.com is in. example.com itself is not: the pattern requires that leading dot. If you need both, list both. It's an easy way to end up excluding the exact origin the list was written for.

There's a fifth, and it's the one that costs the most time: your policy can be perfect and the frame can still fail. The framing headers only decide whether the browser will load the page. Whether the application works inside the frame depends on cookies, and a session cookie that hasn't opted in to cross-site use generally won't be sent into a third-party frame, whatever your CSP says. That's a separate problem with a separate fix, covered in cookies inside an iframe.

Testing what you've written

One request shows you the current value:

curl -sIL https://example.com | grep -i content-security-policy

Follow redirects and read every hop, because the page that answers may not be the page you asked for. To test a change before it can break anything, send it in Content-Security-Policy-Report-Only with a reporting endpoint, collect the violations, then promote it. Just remember that a report-only policy is not enforcing anything and is not overriding your legacy header while it runs.

If you'd rather start from what other sites actually do, can I embed this site in an iframe? has the measured value for all 60 apps we tested, with the raw strings and the limits of the test spelled out.

Questions

Does frame-ancestors block anything besides an iframe?

Yes. The directive covers the whole family of elements that embed one document in another: <frame>, <iframe>, <object>, and <embed>. It's the mirror image of frame-src, which governs what your own page is allowed to load, and it's the reason frame-ancestors is the right tool for "who may embed me" rather than "what may I embed". The two directives point in opposite directions and are easy to confuse.

Can I set frame-ancestors with a meta tag?

No. The CSP specification explicitly excludes frame-ancestors, along with report-uri and sandbox, from the directives a <meta> element can deliver. The browser needs the policy when the response arrives, before it parses any HTML, so a directive that appears inside the document is simply too late to be useful. It must come from a real Content-Security-Policy response header.

Why is my frame still blank when frame-ancestors names my site?

Check the whole redirect chain first: the response that finally renders may come from a different URL than the one you framed, carrying a different policy. If the chain is clean, the next suspect is the source list itself — a non-default port, a wildcard that misses the bare domain, or a path that can never match an origin. And if the browser is genuinely allowing the frame but the app inside it shows a login screen, the headers are no longer your problem: the session cookie is.