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.

Origin boundary between the page and the framed tag The parent origin holds the page DOM, first-party cookies and a bridge listener with an origin allowlist. Across an origin boundary, a framed document with an opaque origin runs the vendor tag, is denied cookies and local storage, and sends its own beacon. Only forwarded events and acknowledgements cross the boundary. Parent origin https://shop.example page DOM + first-party cookies bridge listener + allowlist forwards page-level events origin boundary events ready Framed origin opaque origin — null vendor tag runs here cookies + localStorage denied beacon to vendor endpoint

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.

Load and handshake sequence The parent page loads the loader document, which fetches and runs the vendor tag, replies with a ready message that the parent validates by origin, then receives forwarded page events sent with an exact target origin before the tag emits its own beacon. Parent page Framed loader Vendor CDN 1. load loader.html 2. GET vendor tag 3. 200 — script runs 4. ready (origin checked) 5. forwarded page event targetOrigin = frame origin 6. beacon to vendor parent DOM never exposed

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.

Containment comparison matrix Five capabilities compared across three placements: inline on the main page, a sandboxed frame with an opaque origin, and a separate-origin frame with a bridge. Green cells are good for containment, red and orange cells are not. Capability of the vendor tag Inline on the main page Sandboxed frame (opaque origin) Separate-origin frame + bridge Reads the parent DOM yes no no First-party cookies yes no — throws no localStorage of main origin yes no no Exact postMessage origin n/a no — null yes Blast radius if hostile whole page frame only frame only

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • allow-scripts plus allow-same-origin is an escape hatch when the frame is same-origin with the page. A document that shares your origin can reach window.parent.document, find its own <iframe> element and delete the sandbox attribute, 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 the src origin, 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 a MessagePort once on load and treat later traffic as port-only. Never send '*' messages that contain user data.

  • frame-src on the parent says nothing about what the frame loads. Content Security Policy is not inherited across a document boundary; the loader needs its own script-src and connect-src response headers or the vendor tag can pull in any host it likes. The one directive that does cross is sandbox, whose flags are inherited by nested frames.

  • integrity without crossorigin="anonymous" fails closed. The bridge script in the parent example carries both. Drop the crossorigin attribute 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; Partitioned and 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.

Permalink to "Related"

Related Articles

Applying SRI to Google Tag Manager
Self-Hosting Third-Party Scripts
Third-Party Tag & Analytics Integrity Asset Hashing & Dynamic Script…