Vendor scripts loaded via standard DOM insertion inherit the full window scope of your page. When a marketing pixel writes window.dataLayer, a chat widget patches window.MutationObserver, or a consent manager polls window.__tcfapi before the user has acted, you get compliance violations, race conditions, and unpredictable Core Web Vitals regressions — all triggered by a single, avoidable architectural assumption: that shared global scope is safe.

Triage: Reproduce and Confirm Scope Leakage

Before applying an isolation strategy, confirm that scope leakage is the actual cause of the symptom you are seeing.

Measurable fix target: window property growth of three or fewer new properties per vendor; main-thread blocking from any single third-party script below 50 ms.

Root Cause: Synchronous Injection into a Shared Execution Context

The core problem is that a <script> tag inserted into your document — whether placed in the HTML or injected via document.createElement — always executes inside the same JavaScript realm as your first-party code. The vendor’s top-level var declarations become window properties, window.addEventListener calls intercept your own events, and prototype mutations on built-ins like Array or Promise affect your entire application.

This architectural reality is described in the building secure iframes for third-party widgets guide, which covers the full isolation model. The specific browser mechanic here is the JavaScript realm: each browsing context (tab, iframe, Worker) owns exactly one realm with its own window, prototype chain, and global environment record. The only standards-compliant way to create a second realm inside a page is a cross-origin iframe or a Worker — there is no other escape hatch.

For consent-gated scripts specifically, the architecting GDPR-compliant consent gating guide explains why shared-scope polling of window.__tcfapi is the primary source of pre-consent data leakage.

Resolution: Sandboxed iframe with a postMessage Bridge

The production-grade fix moves vendor execution into an iframe whose sandbox attribute omits allow-same-origin. This creates an opaque origin (null) for the embedded document, completely severing its access to the host window, cookies, localStorage, and DOM.

The diagram below shows the isolation boundary and message flow:

postMessage isolation boundary The host page owns window. A sandboxed iframe with opaque origin contains the vendor script. The two sides communicate only through a schema-validated postMessage channel; there is no direct property access across the boundary. Host Page window (first-party realm) dataLayer, __tcfapi, etc. postMessage listener validates origin + schema iframe element sandbox="allow-scripts" INIT config events / results opaque origin (null) Sandboxed iframe isolated window no cookie / storage access vendor script full execution, no escape postMessage emitter sends typed events to host no direct window access

Step 1 — Create the sandboxed iframe

/**
 * createVendorSandbox
 * Loads a vendor script inside an opaque-origin sandbox.
 * @param {string} vendorSrc - URL of the vendor script to isolate
 * @param {object} initConfig - serialisable config object sent to the vendor
 * @returns {HTMLIFrameElement}
 */
function createVendorSandbox(vendorSrc, initConfig) {
  const iframe = document.createElement('iframe');

  // allow-scripts: vendor code may execute JavaScript
  // No allow-same-origin: the iframe runs as an opaque origin (null),
  // severing all access to the host window, cookies, and localStorage.
  iframe.setAttribute('sandbox', 'allow-scripts');

  // srcdoc avoids an extra network request and keeps the bootstrap inline.
  iframe.srcdoc = `<!doctype html>
<html>
<head>
  <script>
    // Step A: Listen for INIT from the host before loading the vendor.
    window.addEventListener('message', function onInit(e) {
      // event.origin is the string 'null' for opaque-origin iframes.
      // Use event.source to verify the message came from the host frame.
      if (e.source !== window.parent) return;
      if (!e.data || e.data.type !== 'INIT') return;

      window.removeEventListener('message', onInit);

      // Step B: Initialise the vendor with the host-supplied config.
      vendorInit(e.data.config);
    });

    // Step C: Load the vendor script after the listener is in place.
    var s = document.createElement('script');
    s.src = ${JSON.stringify(vendorSrc)};
    s.async = true;
    document.head.appendChild(s);
  <\/script>
</head>
<body></body>
</html>`;

  // The iframe must be in the DOM before postMessage can reach it.
  iframe.style.display = 'none';
  document.body.appendChild(iframe);

  // Step D: Send config once the iframe's document is ready.
  iframe.addEventListener('load', function () {
    iframe.contentWindow.postMessage(
      { type: 'INIT', config: initConfig },
      '*'  // target origin is '*' because the iframe is opaque-origin (null)
    );
  });

  return iframe;
}

Step 2 — Receive events from the iframe in the host

/**
 * Listen for validated events from sandboxed vendor iframes.
 * Call this once at application startup, before any sandbox is created.
 */
function installVendorBridge(trustedIframes) {
  window.addEventListener('message', function (e) {
    // For opaque-origin iframes, e.origin is the string 'null'.
    // Validate by checking the source against the known iframe set instead.
    if (!trustedIframes.has(e.source)) return;

    // Enforce a strict event schema — reject anything unexpected.
    if (!e.data || typeof e.data.type !== 'string') return;

    switch (e.data.type) {
      case 'CONSENT_READY':
        // Forward to your first-party consent state manager.
        handleConsentSignal(e.data.payload);
        break;
      case 'ANALYTICS_EVENT':
        // Relay to the first-party dataLayer without exposing window directly.
        window.dataLayer = window.dataLayer || [];
        window.dataLayer.push(e.data.payload);
        break;
      default:
        // Silently drop unknown event types.
        break;
    }
  });
}

const sandboxRegistry = new Set();
const vendorFrame = createVendorSandbox(
  'https://vendor.example.com/widget.js',
  { clientId: 'abc123', region: 'EU' }
);
sandboxRegistry.add(vendorFrame.contentWindow);
installVendorBridge(sandboxRegistry);

Verification: Confirm the Isolation Boundary Holds

After deploying the sandbox, run this single check — it is the definitive confirmation that the vendor can no longer write to your host window:

// Run in DevTools Console after the sandboxed vendor has initialised.

const before = new Set(Object.keys(window));

// Trigger any vendor action that would normally inject globals.
document.dispatchEvent(new CustomEvent('vendor-action', { detail: {} }));

// Wait one microtask tick for synchronous mutations.
await new Promise(r => setTimeout(r, 100));

const added = Object.keys(window).filter(k => !before.has(k));
console.assert(
  added.length === 0,
  `Scope leak detected — vendor added: ${added.join(', ')}`
);

In the Performance panel, record the same page load as your baseline. Total Blocking Time attributed to the vendor script should drop to near zero, because the vendor’s JavaScript now executes on the iframe’s rendering thread rather than competing with first-party work on the main document’s thread.

One property of this arrangement is worth stating before the pitfalls: the bridge is now the entire attack surface, so its message handler deserves the scrutiny you would give an HTTP endpoint. Validate shape as well as origin.

Treat the bridge handler like an HTTP endpoint Four checks applied in order to an inbound bridge message. First, the origin must match the expected frame origin exactly, compared as a string not a prefix. Second, the source must be the window reference you created, which defeats a message forged by another frame on the same origin. Third, the payload must match an expected shape, with a known message type and only known fields. Fourth, the values must be within range, because a well-shaped message can still carry hostile values. event.origin === exact string, not startsWith event.source === the window you created known type, known fields reject anything else outright values in range shape is not safety The second check is the one usually missing: any frame on the expected origin can post to your listener. Comparing the source reference narrows that to the one frame you actually embedded. Fail closed on every check: an unrecognised message is dropped, never partially handled.

Common Pitfalls

  • Skipping event.source validation. When an iframe has an opaque origin, event.origin is the string "null" — not a URL. Any page can send postMessage with origin: null spoofed through older browser quirks. Always cross-check event.source against the specific iframe.contentWindow reference you control.

  • Using allow-same-origin alongside allow-scripts. This combination defeats the entire sandbox. A script that has allow-scripts and allow-same-origin can read and write the host page’s cookies, localStorage, and DOM freely. The two flags must not coexist if isolation is the goal.

  • Blocking consent API writes with a Proxy, then wondering why the CMP fails. If you use a JavaScript Proxy scope to restrict window.__tcfapi writes before your CMP initialises, the CMP cannot register its API and subsequent consent checks silently return null. Either whitelist the consent API endpoints explicitly in the Proxy handler, or — better — keep the CMP in your first-party scope and sandbox only the downstream marketing pixels that consume the consent signal. See implementing strict content security policies for how CSP nonces complement this sandbox pattern without disrupting CMP initialisation.

Shared context versus a bridged boundary On the left, a script in the shared context reaches the whole global object: your application state, document cookie, storage, every prototype it may patch, and every listener already registered. On the right, the same script inside a sandboxed frame reaches only its own opaque-origin globals, and everything it can obtain from your page is whatever the bridge chooses to send — a fixed, reviewable list. shared context — reachable window.__APP_STATE__ document.cookie · localStorage Array.prototype · fetch · history every listener already registered the list is everything, and it grows as you build bridged frame — reachable its own opaque-origin globals plus exactly the fields the bridge sends { event, value, ts } the list is finite, written down, and reviewable in a diff The security property is not that the frame is smaller — it is that the reachable set is enumerable. Which means a widening of that set shows up in code review rather than in an incident.

Frequently Asked Questions

Is freezing prototypes an adequate alternative to a frame?

It raises the cost of tampering without changing what is reachable. Object.freeze on a few prototypes stops the crudest patching, but the script still shares your realm: it can read document.cookie, enumerate your globals, register listeners, and reach anything you have already attached to window. Hardening a shared context is a mitigation; a separate context is a boundary.

Freezing is also fragile in a way boundaries are not — it must be applied before the third party runs, must cover everything worth protecting, and breaks legitimate libraries that extend built-ins. Where you cannot use a frame, prefer removing the vendor over hardening around it.

How do we know the bridge is not leaking more than intended?

Make the payload construction explicit rather than incidental. Build the outgoing object field by field from named values, never by spreading an application object, and the diff of that function is the complete record of what crosses the boundary. A test that asserts the exact key set of an outgoing message will fail the moment someone adds a field, which is precisely when you want to know.

Log the outgoing payloads in a staging environment and read them. It is common to discover that an object assembled from convenience carries a user identifier or a full URL with query parameters that nobody intended to hand to the vendor.


Related

Up: Building Secure Iframes for Third-Party Widgets