Sandboxing Analytics Scripts with Iframes
Permalink to "Sandboxing Analytics Scripts with Iframes"Part of Third-Party Tag & Analytics Integrity, this page shows how to contain a vendor tag that can never carry a hash by giving it an origin of its own, then handing it only the events it needs through a validated message bridge.
Quick Reference
Permalink to "Quick Reference"| Control | Value | Effect |
|---|---|---|
sandbox attribute |
allow-scripts |
Runs scripts; document gets a fresh opaque origin |
sandbox attribute |
allow-scripts allow-same-origin |
Keeps the frame’s own origin — safe only when it differs from the parent |
allow attribute |
"" |
Delegates no Permissions Policy features to the frame |
referrerpolicy |
no-referrer |
The frame’s document.referrer is empty; forward the page URL yourself |
| CSP directive | frame-src https://tags.shop.example |
The parent may frame that origin and no other |
| CSP directive | frame-ancestors https://shop.example |
Only your page may frame the loader |
| CSP directive | sandbox allow-scripts |
Sandbox enforced by the loader’s own response headers |
postMessage target |
exact origin string | Refuses delivery if the frame is not on that origin |
event.origin |
"null" for opaque frames |
Serialised opaque origin — not a usable identity |
credentialless attribute |
Chromium-only today | Loads the frame in an ephemeral, credential-free context; check current support |
Default posture: a loader page on a separate registrable domain, sandbox="allow-scripts allow-same-origin", frame-src pinned to that one origin, and a message contract with a fixed set of types.
The mental model
Permalink to "The mental model"Subresource Integrity is a promise about bytes: you assert a hash, the browser refuses anything else. That promise is unavailable the moment a vendor ships a mutable tag URL whose body is rewritten whenever they release, which is how nearly every analytics, consent and session-replay product works. When you cannot control the bytes, the remaining lever is the authority those bytes execute with. A script tag on your page inherits everything your page has: the DOM, the cookie jar, localStorage, in-flight form values, and the ability to inject more scripts. Move the same file into a document on a different origin and it inherits none of that.
The boundary being enforced here is the same-origin policy, not the integrity check. Two documents share an origin only when scheme, host and port all match, and a cross-origin document has no scripted access to the parent’s DOM or storage. The sandbox attribute goes further: without allow-same-origin the framed document is assigned a fresh opaque origin, so it is cross-origin even to other copies of itself, and document.cookie, localStorage and IndexedDB all throw rather than return data. Containment is therefore a spectrum, and picking a point on it is a decision about how much the tag still needs to work.
What survives the move is anything you deliberately hand over. The vendor still gets a document, a network stack and a beacon endpoint, and you can feed it page-level facts — a route change, a purchase total, a consent flag — as structured messages. What dies is everything implicit: autotracking, first-party cookie identity, and the vendor’s ability to read parts of the page you never intended to share. Deciding which tags are worth this effort is the job of Scoring Third-Party Script Risk; this page assumes you already have a tag that scored badly and cannot be pinned.
Canonical example: a loader page on its own origin
Permalink to "Canonical example: a loader page on its own origin"The production-grade arrangement is a one-page document served from a host you control but that is not your application origin — tags.shop.example here, ideally on a separate registrable domain if you want a true storage boundary. Because that document is genuinely cross-origin to the page, granting allow-same-origin is safe: the flag means “keep your own origin”, not “share the parent’s”. The frame then has a real, checkable origin on both ends of the bridge, which is what makes strict targetOrigin validation possible.
The parent page embeds it and loads a bridge script of its own:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Checkout — shop.example</title>
</head>
<body>
<!-- application markup … -->
<iframe
id="tag-frame"
src="https://tags.shop.example/analytics-loader.html"
sandbox="allow-scripts allow-same-origin"
allow=""
referrerpolicy="no-referrer"
title="Analytics container"
aria-hidden="true"
tabindex="-1"
width="0"
height="0"
style="border:0;position:absolute;left:-9999px;"></iframe>
<script
src="/js/tag-bridge.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
</body>
</html>
Serve the page with a policy that names the frame origin and nothing else. frame-src is the directive that governs which documents may be embedded; combining it with the rest of your script controls is covered in Configuring Content Security Policy with SRI.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; frame-src https://tags.shop.example; connect-src 'self'; base-uri 'none'; object-src 'none'" always;
The parent-side bridge keeps a queue until the frame reports itself ready, validates every inbound message, and refuses to accept anything that did not come from the frame’s window object:
// /js/tag-bridge.js — loaded on the application origin
'use strict';
const FRAME_ORIGIN = 'https://tags.shop.example';
const frame = document.getElementById('tag-frame');
const CONTRACT = {
'page:view': { path: 'string', title: 'string' },
'page:event': { name: 'string', value: 'number' },
'consent:update': { analytics: 'boolean' }
};
const queue = [];
let ready = false;
function build(type, payload) {
if (!Object.hasOwn(CONTRACT, type)) throw new Error(`unknown type: ${type}`);
const shape = CONTRACT[type];
const out = { v: 1, type, payload: {} };
for (const [key, kind] of Object.entries(shape)) {
if (typeof payload[key] !== kind) throw new Error(`bad field: ${key}`);
out.payload[key] = payload[key];
}
return out;
}
export function send(type, payload) {
const msg = build(type, payload);
if (!ready) { queue.push(msg); return; }
frame.contentWindow.postMessage(msg, FRAME_ORIGIN);
}
window.addEventListener('message', (event) => {
if (event.origin !== FRAME_ORIGIN) return; // who sent it
if (event.source !== frame.contentWindow) return; // which window
const msg = event.data;
if (!msg || msg.v !== 1 || msg.type !== 'tag:ready') return;
ready = true;
while (queue.length) frame.contentWindow.postMessage(queue.shift(), FRAME_ORIGIN);
});
The loader document itself contains no application logic. Its own bridge runs first so that the vendor tag finds a populated queue when it initialises:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Analytics container</title>
</head>
<body>
<script src="/frame-bridge.js"></script>
<script src="https://vendor.example/tag.js" async></script>
</body>
</html>
// https://tags.shop.example/frame-bridge.js
'use strict';
const PARENT_ORIGIN = 'https://shop.example';
const CONTRACT = {
'page:view': { path: 'string', title: 'string' },
'page:event': { name: 'string', value: 'number' },
'consent:update': { analytics: 'boolean' }
};
window.dataLayer = window.dataLayer || [];
function valid(msg) {
if (!msg || typeof msg !== 'object' || msg.v !== 1) return false;
if (typeof msg.type !== 'string' || !Object.hasOwn(CONTRACT, msg.type)) return false;
const shape = CONTRACT[msg.type];
const payload = msg.payload;
if (!payload || typeof payload !== 'object') return false;
const keys = Object.keys(payload);
if (keys.length !== Object.keys(shape).length) return false;
return keys.every((k) => Object.hasOwn(shape, k) && typeof payload[k] === shape[k]);
}
window.addEventListener('message', (event) => {
if (event.origin !== PARENT_ORIGIN) return;
if (event.source !== window.parent) return;
if (!valid(event.data)) return;
window.dataLayer.push({ event: event.data.type, ...event.data.payload });
});
window.parent.postMessage({ v: 1, type: 'tag:ready' }, PARENT_ORIGIN);
Both handlers reject on origin before they look at the payload, both pin the expected window, and both refuse messages with unknown types or extra keys. The dataLayer array is the queue convention used by Google Tag Manager, so an existing container drops straight in — see Applying SRI to Google Tag Manager for what can and cannot be hashed inside it.
The loader gets its own response headers, because a parent policy does not reach into a framed document:
add_header Content-Security-Policy "default-src 'none'; script-src 'self' https://vendor.example; connect-src https://collect.vendor.example; img-src https://collect.vendor.example; frame-ancestors https://shop.example; base-uri 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
Discovering the full host list a tag actually reaches is rarely documented; run the policy in report-only mode first, as described in Rolling Out a Script Policy in Report-Only Mode, and promote it once the reports go quiet.
Variants
Permalink to "Variants"Opaque origin with a MessagePort handshake
Permalink to "Opaque origin with a MessagePort handshake"Drop allow-same-origin and the document becomes opaque-origin regardless of where it was served from. That is the strictest option: document.cookie, localStorage and IndexedDB all throw, so the tag cannot persist anything at all between page loads. The cost is addressability. An opaque origin serialises to the string null, so the parent cannot name it in postMessage, and the frame’s messages arrive with event.origin === "null".
The workaround is to hand the frame a private channel as early as possible and stop using window.postMessage afterwards:
// Parent: opaque frame cannot be named, so send the port with "*" once, immediately.
const channel = new MessageChannel();
frame.addEventListener('load', () => {
frame.contentWindow.postMessage({ v: 1, type: 'bridge:port' }, '*', [channel.port2]);
});
channel.port1.onmessage = (event) => {
const msg = event.data;
if (!msg || msg.v !== 1 || msg.type !== 'tag:ready') return;
ready = true;
};
// Frame: the parent's origin is real, so this side can still check it strictly.
window.addEventListener('message', (event) => {
if (event.origin !== 'https://shop.example') return;
if (event.source !== window.parent) return;
if (!event.data || event.data.type !== 'bridge:port') return;
const port = event.ports[0];
if (!port) return;
port.onmessage = (e) => { if (valid(e.data)) window.dataLayer.push(e.data); };
port.postMessage({ v: 1, type: 'tag:ready' });
});
Be honest about the residual risk: '*' means the parent cannot prove which document received the port. A sandboxed frame is still allowed to navigate itself, so if the vendor script replaces the frame’s own document before the handshake, the port goes to whatever is there now. Send it on load and never re-send it.
Subdomain versus a separate registrable domain
Permalink to "Subdomain versus a separate registrable domain"tags.shop.example is a different origin from shop.example but the same site. That distinction matters twice. Cookies written with Domain=shop.example are visible to every subdomain, so a same-site loader can read them unless your first-party cookies are host-only; and browser storage partitioning keys third-party storage by top-level site, so a same-site frame is not partitioned at all. If the point of the exercise is a hard data boundary, buy a separate domain for the loader. If the point is only to keep the tag out of your DOM, a subdomain is enough and saves a DNS and certificate hop.
Enforce the sandbox from the loader’s response
Permalink to "Enforce the sandbox from the loader’s response"The sandbox attribute lives in the parent’s markup, which means a copy-pasted embed can silently omit it. The CSP sandbox directive applies the same flags from the framed document’s own response, so containment travels with the document:
add_header Content-Security-Policy "sandbox allow-scripts; default-src 'none'; script-src 'self' https://vendor.example; connect-src https://collect.vendor.example" always;
The header form has the same trade-off as the attribute: omitting allow-same-origin yields an opaque origin, so pair it with the port handshake above.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
allow-scriptsplusallow-same-originis an escape hatch when the frame is same-origin with the page. A document that shares your origin can reachwindow.parent.document, find its own<iframe>element and delete thesandboxattribute, then reload itself unsandboxed. The pairing is only safe when the framed document is served from a host that is genuinely different from the embedding page — verify thesrcorigin, not the intent. -
An opaque frame cannot be addressed by origin, and
"*"is a real weakening.postMessage(msg, '*')delivers to whatever document currently occupies that frame. Since a sandboxed frame may navigate itself, the safe pattern is to transfer aMessagePortonce onloadand treat later traffic as port-only. Never send'*'messages that contain user data. -
frame-srcon the parent says nothing about what the frame loads. Content Security Policy is not inherited across a document boundary; the loader needs its ownscript-srcandconnect-srcresponse headers or the vendor tag can pull in any host it likes. The one directive that does cross issandbox, whose flags are inherited by nested frames. -
integritywithoutcrossorigin="anonymous"fails closed. The bridge script in the parent example carries both. Drop thecrossoriginattribute from a cross-origin tag and the browser fetches it in no-cors mode, gets an opaque response it cannot hash, and blocks the script outright — the failure looks like a network problem, not a policy one. The mechanics are in How CORS and crossorigin Affect SRI. -
Storage partitioning quietly changes the numbers. Current browsers key third-party storage and cookies by top-level site, so the identifier the vendor writes inside your frame is not the one it writes on another site. If the tag needs frame-local persistence, set its cookies
Secure; SameSite=None; Partitionedand expect cross-site stitching to stop working entirely.
Verification Steps
Permalink to "Verification Steps"1. Confirm the frame’s origin and storage posture
Permalink to "1. Confirm the frame’s origin and storage posture"Open DevTools, switch the console’s execution context to the frame, and run:
console.log(window.origin);
try { localStorage.getItem('probe'); console.log('storage: readable'); }
catch (e) { console.log('storage:', e.name); }
With sandbox="allow-scripts" alone, the expected output is:
null
storage: SecurityError
With allow-same-origin added and the loader on its own host, the first line is https://tags.shop.example and storage is readable but partitioned.
2. Confirm the parent policy pins the frame origin
Permalink to "2. Confirm the parent policy pins the frame origin"curl -sI https://shop.example/checkout | grep -i '^content-security-policy'
The response must contain a frame-src https://tags.shop.example entry. Then point the src at any other host and reload — the console reports:
Refused to frame 'https://elsewhere.example/' because it violates the following Content Security Policy directive: "frame-src https://tags.shop.example".
3. Confirm forged messages are dropped
Permalink to "3. Confirm forged messages are dropped"From the console of the top-level page, impersonate the frame:
window.postMessage({ v: 1, type: 'tag:ready' }, '*');
Nothing should happen: event.origin is https://shop.example, not the frame origin, so the handler returns before touching the payload. Add a temporary console.log after the origin check to prove the early return, then remove it.
4. Confirm the loader refuses foreign embedders
Permalink to "4. Confirm the loader refuses foreign embedders"curl -sI https://tags.shop.example/analytics-loader.html | grep -i '^content-security-policy'
Expect a frame-ancestors https://shop.example entry. Embed the loader from a scratch page on another origin and the browser blocks the load with Refused to display 'https://tags.shop.example/analytics-loader.html' in a frame because an ancestor violates the following Content Security Policy directive: "frame-ancestors https://shop.example".
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Does a sandboxed iframe stop a vendor script from reading my checkout form?
Yes, as long as the framed document is never same-origin with the page. A cross-origin document cannot read the parent DOM, so form fields, first-party cookies and localStorage of the main origin are all out of reach. What it can still see is whatever you choose to forward across the bridge, so keep the message contract narrow and never forward raw form values.
Why does event.origin arrive as the string null?
Because the frame has an opaque origin. A sandbox attribute without allow-same-origin gives the document a fresh opaque origin no matter which server it came from, and opaque origins serialise to the string null. You cannot send to such a frame with an exact targetOrigin either, which is why the opaque variant relies on a MessagePort handshake instead.
Can the framed vendor script still carry an integrity attribute?
Only if the vendor publishes an immutable, versioned URL. Most tag endpoints are mutable by design and change content without changing the URL, which is exactly why hashing them is impossible and containment is the fallback. If a versioned build does exist, pin it with a SHA-384 hash and crossorigin=anonymous inside the frame and keep the frame anyway.
Will my analytics numbers change after moving a tag into a frame?
Almost certainly. The vendor loses the first-party cookie it used for visitor identity, so returning visitors may be counted as new; storage inside the frame is partitioned by top-level site in current browsers; and automatic collection of page URL, title, referrer, scroll depth and click targets stops until you forward those events yourself. Rebaseline before comparing periods.
Does a hidden iframe still run the vendor script?
Yes. A frame hidden with CSS or sized zero by zero is still loaded and its scripts still execute; only display suppression changes. A cross-origin frame is usually placed in its own renderer process under Chromium site isolation and Firefox Fission, so the vendor’s parsing and execution work no longer competes with your main document’s main thread.
Related
Permalink to "Related"- Self-Hosting Third-Party Scripts — the other answer to an unpinnable tag: take a copy, hash it, and accept the update burden
- Detecting Changes in Third-Party Scripts — watching a mutable vendor URL so you learn about a rewrite before your users do
- Deploying Defense-in-Depth Script Controls — where frame containment sits among nonces, hashes and Trusted Types