← Back to series

Iframe isolation and CSP nonces: Safie's 2.61 MB body

Part 2 of 6 How to build a JavaScript SDK: 5 real teardowns

SDKs come in very different sizes. The YouTube loader I read in YouTube iframe API: what 993 bytes actually ship is only 993 bytes, small enough to read the whole source in one sitting. Safie, the subject of this part, sits at the other extreme: the loader that lands on the customer’s site is 160 KB, and the iframe body it calls up (the code that does the actual work) is 2.61 MB. Both are “SDKs you embed with one script line”, yet their scale and the way they divide the work could hardly be more different.

Why is it built this lopsided, what is it protecting inside the iframe, and how do parent and child talk to each other? The implementation explains all of it.

Measured sizes

HTTP body sizes as of 2026-08-08.

ResourceRawRole
safie.link/sdk/js/api/v1/latest/ (loader)160 KB (gzip 37 KB)The <script> on the customer’s site
lib/iframe.html1.2 KBEntry point of the iframe body (with a CSP header)
lib/main-*.js999 KBCore UI
lib/styles-*.css469 KBUI styles
lib/chunk-*.js × 31.08 MBLazy-loaded code
lib/polyfills-*.js64 KBAngular-style polyfills
iframe body total2.61 MB

160 KB sits on the customer’s site and calls up a separate 2.61 MB world. That asymmetry is at the center of how Safie designed its SDK.

Bars proportional to size comparing the parts of Safie's SDK. The loader embedded in the customer's site (safie.link/sdk/js/api/v1/latest/) is 160 KB (gzip 37 KB). The iframe body on Safie's own origin totals 2.61 MB: main (core UI) 999 KB, chunk ×3 (lazy-loaded) 1.08 MB, styles 469 KB, polyfills 64 KB. The entry point iframe.html is 1.2 KB (served with a CSP header), too thin to show as a bar. The body is about 16 times the loader, and a full UI framework fits inside without clashing with the customer's React or CSS. HTTP body sizes measured on 2026-08-08

Why the 2.61 MB lives outside the customer’s site

A camera-feed SDK has more jobs than an ordinary embedded player:

  • The WebSocket connection for live video (wss://*.safie.link)
  • Holding and automatically refreshing auth tokens
  • A timeline UI (rewinding and seeking through recordings)
  • PTZ control (Pan/Tilt/Zoom)
  • Still captures, thumbnails, a location API

Put all of that on the customer’s site as a Web Component injected straight into the DOM, and it collides with the customer’s React version, their CSS, their Content-Security-Policy and their cookie domain. Companies that adopt a camera-feed SDK often run a strict CSP on their own site, and my read is that there is little room to bring a large library in (I haven’t actually surveyed customers’ CSPs).

Safie chose to show customers only a 160 KB proxy <script> and lock the body inside an iframe on its own origin. Inside the iframe it can build freely with its own CSP, its own cookies and its own UI framework. The judgment is that this freedom is worth the cost of shipping 2.61 MB.

Table figure comparing, on four points, putting the 2.61 MB directly in the customer's DOM versus in an iframe on your own origin. Title: put 2.61 MB in the customer's DOM and it collides in four places. Directly in the customer's DOM (if embedded as a Web Component): the UI framework collides with the customer's React version, CSS collides with the customer's styles, it runs under the customer's CSP with little room to bring in a large library (my estimate), and it collides with cookies on the customer's domain. In an iframe on your own origin (what Safie chose): bring your whole UI framework as is, build your UI freely, use your own CSP (iframe.html headers, with a per-request nonce and a locked-down connect-src), and cookies on your own domain (safie.link). Only the 160 KB loader lands on the customer's page; the price is carrying 2.61 MB inside the iframe. Sizes measured 2026-08-08

Four iframes

Reading the loader, there are four places where it creates an iframe. All of them point at the same iframe.html, and the hash fragment splits the roles.

// excerpt (formatted)
iframe.src = `${Te.iFrameURL}#safie-sdk-proxy`
iframe.src = `${Te.iFrameURL}#safie-sdk-streaming-player`
iframe.src = `${Te.iFrameURL}#safie-sdk-timeline-context`
iframe.src = `${Te.iFrameURL}#safie-sdk-timeline`

#safie-sdk-proxy is an invisible iframe that relays RPC. Every method call, authentication and device queries included, goes through it. The other three are UI components that actually render on the customer’s page (live playback and the timeline).

The hash fragment lets the same URL be reused so the browser cache works, while the JS inside the iframe reads location.hash to decide “am I the proxy or the streaming player?”. Delivery stays simpler than giving each kind of iframe its own URL.

Safie's loader opens the same lib/iframe.html (the entry point with a CSP, 1.2 KB) four times and splits roles by hash. #safie-sdk-proxy is a hidden iframe that relays method calls (auth and device queries go through here). #safie-sdk-streaming-player is UI for live playback. #safie-sdk-timeline is UI for the timeline. #safie-sdk-timeline-context is UI, a panel opened from the timeline (hidden until needed). Below: because there is only one URL, all four iframes share the browser cache; the JS inside reads location.hash to decide whether it is the proxy or the player

Measuring the CSP inside the iframe

Grab the headers of iframe.html with curl -I:

content-security-policy:
  default-src 'self';
  script-src 'self' 'nonce-2b57f8c8007890cd4abf6a1c4079f555';
  style-src  'self' 'nonce-2b57f8c8007890cd4abf6a1c4079f555';
  img-src    'self' blob: data:;
  media-src  'self' blob:;
  connect-src 'self' https://*.safie.link wss://*.safie.link blob:;
  worker-src  'self' blob:;
  font-src   'self' https://safie.link https://fonts.gstatic.com;

Three things are worth reading closely.

1. A per-request nonce on script-src / style-src

nonce-2b57f8c8... changes on every request; fetch iframe.html again and you get a different nonce. Only scripts whose <script nonce="..."> in the iframe’s HTML matches it will run, so even if XSS injects <script>alert(1)</script>, the nonce won’t match and it won’t execute. This is hard to run on static hosting; you need a server that attaches the header dynamically. Safie is presumably splitting the work: iframe.html alone comes from that server, and the JS/CSS that follows, bound by the nonce, comes from the delivery CDN.

2. connect-src closed to Safie’s own domains

connect-src restricts where fetch/XHR/WebSocket can go. With only https://*.safie.link wss://*.safie.link blob: listed, even if the JS inside the iframe were hijacked, it could not carry data out to an external API. The browser itself structurally closes off where a camera-feed auth token could leak to.

What the CSP in Safie's iframe.html (fetched with curl -I on 2026-08-08) blocks, in two columns. Left, which scripts may run (script-src / style-src: 'self' 'nonce-2b57f8c8…', the nonce changes on every fetch of iframe.html): a script with the matching nonce (written into iframe.html by Safie) runs; a script injected via XSS (does not know the nonce) is blocked. The header differs on every response, so static hosting alone cannot serve it. Right, where it may connect (connect-src: 'self' https://*.safie.link wss://*.safie.link blob:): https / wss to *.safie.link (e.g. the live-video WebSocket) is allowed; any other external domain is blocked even if JS inside the iframe tries. Even if the code is hijacked, the browser closes off where auth tokens could leak to. Also, worker-src and media-src allow blob: (live video is handled with Media Source Extensions)

3. worker-src blob: and media-src blob:

Web Workers and blob URLs are allowed so live video can be processed with Media Source Extensions. It is the minimum permission, worked backward from what a camera-feed SDK needs.

Reading the parent-child handshake in the code

Sequence diagram of Safie's SYN/ACK + AES-GCM handshake. The child iframe (safie.link) creates the keys and sends the private key and the wrapped AES key to the parent (the customer site) in the SYN. The parent unwraps the AES key and replies with an encrypted ACK, and the child replies with connected: three steps. After that, only commands from parent to child are encrypted

The parent (loader) and child (iframe body) share the same MessageSender class, and it is the child that creates the keys. On 2026-09-30 I fetched the loader and the iframe body’s main-*.js again and read the relevant code (formatted excerpt):

// child (iframe body): startCryptoHandshake()
const pair = await crypto.subtle.generateKey(
  { name: "RSA-OAEP", modulusLength: 2048, /* ... */ }, false, ["wrapKey", "unwrapKey"])
const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"])
const wrappedAesKey = await crypto.subtle.wrapKey("raw", aesKey, pair.publicKey, { name: "RSA-OAEP" })
this._targetOrigin = "*"
this.postMessage("syn", { privateKey: pair.privateKey, wrappedAesKey })

// parent (loader): on receiving SYN
this._cryptoKey = await crypto.subtle.unwrapKey("raw", data.wrappedAesKey, data.privateKey, /* ... */)
this.postMessage("ack", { id, name })

Three steps:

  1. SYN (child → parent): The child iframe creates an RSA-OAEP key pair and an AES-GCM key, then wraps the AES key with the public key. It sends the wrapped key (wrappedAesKey) along with the private key (privateKey), with the target origin set to "*"
  2. ACK (parent → child): The parent unwraps wrappedAesKey with the private key it received, keeps the AES key, and replies with an ACK encrypted with AES-GCM
  3. connected (child → parent): If the child can decrypt the ACK, it makes the sender’s origin its target from then on and replies "connected"

From here, commands from parent to child are encrypted with AES-GCM. Results and events from child to parent stay in plaintext.

It is TCP’s 3-way handshake reimplemented on top of postMessage, but the way the key is handed over is unusual. The private key and the wrapped key travel in the same message, so anyone who can receive the SYN can extract the AES key. privateKey is created as a non-exportable CryptoKey, yet postMessage’s structured clone hands it to the other side as is. Rather than a defense against interception, it works as an integrity check (the child fails to decrypt, and drops, messages built by anyone without the key) plus obfuscation that keeps tokens from flowing in plaintext. Why the private key is sent at all, I couldn’t tell from the code.

How the origin check is implemented

An excerpt of the origin check on the receiving side of MessageSender:

if (this._targetOrigin !== "*" && e.origin !== this._targetOrigin) return

Simple, but this is the core of postMessage security. On the child side, _targetOrigin stays "*" until the ACK arrives, so this comparison only takes effect after the ACK. Until then, messages are sorted by whether event.source is the parent window. Leave this line out and iframe isolation loses its point, because an attacker can send commands from any site with iframe.contentWindow.postMessage(payload, "*").

Where Safie is careful is in handling the file:// and null origins separately:

e.origin === "null" || e.origin === "file://" ? "*" : e.origin

This exception keeps things working when the SDK is called from a local file or from an iframe with the sandbox attribute (which yields a null origin): instead of rejecting them, it drops the reply’s targetOrigin to *. It balances an operational need (a business customer’s test environment launched from file://, for example) against keeping the safe side separate.

Figure: how _targetOrigin, which Safie's child iframe uses as both send target and check, changes over time. Title: if the ACK comes from null or file://, the origin check stays off. Until the ACK arrives, _targetOrigin = "*", there is no origin check, and messages are routed by whether the sender window (event.source) is the parent. When the ACK arrives from a normal origin (the customer's https://…), _targetOrigin becomes that origin and from then on other origins are dropped. When it arrives from "null" or "file://" (sandboxed iframes, test setups opened from local files), _targetOrigin stays "*" and the origin is never checked. On receive: if (this._targetOrigin !== "*" && e.origin !== this._targetOrigin) return. On ACK: e.origin === "null" || e.origin === "file://" ? "*" : e.origin. Takeaway: the exception that makes test setups work is also a way around the check. Source: MessageSender in safie.link/sdk/js/api/v1/latest/, fetched 2026-10-01. After the key exchange, messages from anyone without the key fail to decrypt and are dropped

Checklist for building your own

Distilled from Safie’s implementation, building an iframe SDK comes down to these six decisions.

  1. One iframe URL with hash branching, or a separate URL per kind?
    Safie takes the first. The trade-off is a shared cache against the cost of branching in JS. With only two or so kinds of iframe, I think separate URLs read more clearly.

  2. Build the CSP all the way to a per-request nonce, or settle for unsafe-inline?
    Nonces need infrastructure that serves headers dynamically. How sensitive the SDK’s data is decides it. For camera feeds or payments, do it; for a video player, it’s overkill.

  3. Close connect-src to your own domains, or allow *?
    Closing it means third-party measurement tools (Google Analytics and the like) can’t run inside the iframe. Safie chose to close it to its own domains.

  4. Build a SYN/ACK handshake, or just fire and forget?
    An SDK where parent and child hold state (an auth session, for example) needs SYN/ACK. If all you send is one-off playback commands, you don’t.

  5. Encrypt the postMessage payload?
    A message sent to a specific window with a specified targetOrigin doesn’t reach sibling iframes. It can be read when you send with "*" and an unintended window receives it, or when another script running in the receiving window adds a listener. If you encrypt, design it so the key doesn’t ride in the same postMessage. Send the private key along the way Safie does, and all you get is an integrity check.

  6. How far to go with exceptions to the origin check?
    Treating null / file:// as exceptions lets the SDK run in business customers’ test environments, but it can become a loophole. Decide based on where the SDK will be used.

Figure: who can read a postMessage sent to a named origin, in four rows. The sender calls target.postMessage(msg, "https://target-origin"). Sibling iframes and other tabs can't read it: the message event fires only on the window whose postMessage was called. If the target window has navigated to another origin, it can't read it either: when the origin doesn't match at delivery time, the message is dropped. Other scripts running in the target window can read it: a hijacked dependency, say, can add a listener and see everything. An unintended window can read it when you send with "*": with no origin check, it arrives whatever the target has turned into. If you encrypt, never put the key in the same postMessage. Safie's SYN carries the private key with the wrapped key, so anyone who receives it can decrypt. Basis: window.postMessage in the HTML Standard (targetOrigin matching)

All of the above assumes you use an iframe. If you control the page the code goes on, you can drop isolation altogether. Rewriting the HTML at the delivery layer and adding one script line is lighter and adds nothing new to manage. I wrote up why I rejected the iframe approach on search-ranking grounds in Ship a product, get a support button for free. An SDK that goes on other people’s sites has no such escape hatch, so it has to go through the six items above.

Next: designing what goes inside postMessage

So far this has been about the “shell” of iframe isolation. The next part turns to the messages that flow inside it. It lines up the postMessage protocols of Vimeo, YouTube and Safie as three generations and compares their handshakes and how strictly they check origins. Then it covers whether to write message types as JSON Schema or as .d.ts, and how to decide when AES-GCM is really worth building and when it isn’t.

Measured 2026-08-08. The direction of the handshake and how the key is handed over were confirmed by fetching the loader and iframe body again on 2026-09-30. Safie’s build ID, CSP nonce and AES-GCM key change every time; the structure does not.