Iframe isolation and CSP nonces: Safie's 2.61 MB body
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.
| Resource | Raw | Role |
|---|---|---|
safie.link/sdk/js/api/v1/latest/ (loader) | 160 KB (gzip 37 KB) | The <script> on the customer’s site |
lib/iframe.html | 1.2 KB | Entry point of the iframe body (with a CSP header) |
lib/main-*.js | 999 KB | Core UI |
lib/styles-*.css | 469 KB | UI styles |
lib/chunk-*.js × 3 | 1.08 MB | Lazy-loaded code |
lib/polyfills-*.js | 64 KB | Angular-style polyfills |
| iframe body total | 2.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.

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.

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.

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.

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

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:
- 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"*" - ACK (parent → child): The parent unwraps
wrappedAesKeywith the private key it received, keeps the AES key, and replies with an ACK encrypted with AES-GCM - 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.

Checklist for building your own
Distilled from Safie’s implementation, building an iframe SDK comes down to these six decisions.
-
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. -
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. -
Close
connect-srcto 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. -
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. -
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 samepostMessage. Send the private key along the way Safie does, and all you get is an integrity check. -
How far to go with exceptions to the
origincheck?
Treatingnull/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.

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.
Was this article helpful?