Sandboxing AI-generated React code: four layers that make it safe to run
When a model writes the React your users see, treat its output as hostile. Here's the defence in depth we use: a shared contract, AST validators, a quality gate and a cross-origin sandbox.

Krofile's AI card engine asks a model to write a React component, then renders that component for anyone who opens the card. Put plainly, we execute code written by a language model in front of the public. If you're building anything similar, such as AI-generated UI, dashboards or widgets, this post covers how to do it without handing the model the keys.
The rule we design around is simple: model output is untrusted input. Not because the model is malicious, but because prompts can be injected, models make mistakes, and "usually fine" isn't a security property.
Layer 1: a shared contract for every renderer
The card renders in three places: the editor, the public card page and an embeddable widget. Early on, each had its own idea of what generated code could touch. That's how bypasses happen: a check added in one renderer and forgotten in another.
So the rules live in one package, card-contract, that every renderer imports. It defines the scope the code runs with (80 allowlisted identifiers such as layout primitives, icons and the card's own data), plus the validators and the hardening step. Change the contract once and all three renderers move together.
Layer 2: validate the AST, not the text
Regex checks on generated code are theatre. We parse the code with Babel and walk the syntax tree, rejecting anything outside the allowlist: fetch, document, window, dynamic import(), reflection through constructor, and so on.
traverse(ast, {
Identifier(path) {
if (!path.isReferencedIdentifier()) return;
const name = path.node.name;
if (!scope.has(name) && !path.scope.hasBinding(name)) {
fail("V-04", `"${name}" is outside the card's scope`);
}
},
MemberExpression(path) {
// obj["constructor"], obj?.constructor and friends
if (isReflection(path.node.property)) fail("V-09", "reflection is not allowed");
},
});The interesting bugs were all bypasses of an earlier version. Optional chaining (a?.constructor) slipped past a check that only looked at plain member access. Destructuring (const { constructor } = x) read the same property without a member expression at all. Each one became a named validator with a regression test, and there are 16 validators today.
Layer 3: a quality gate that can say no
Some failures aren't security issues but still shouldn't reach a customer, such as text that fails contrast against its background. The quality gate runs after validation: it auto-fixes contrast in OKLCH where it can, then gives the model one targeted repair attempt on the failing region only.
If the repair also fails, the customer gets a clear error and their credits back. One repair is a deliberate limit: unbounded retry loops are how an AI feature quietly burns money.
Layer 4: render in a cross-origin sandbox
Validation can have holes we haven't found yet, so the code runs where a hole doesn't matter: an iframe on a separate origin with sandbox="allow-scripts" and nothing else. It can't read cookies, reach the parent's DOM or navigate the top window.
The host and frame talk only through postMessage, and every message carries a nonce the host created for that frame.

When the card wants to do something, like open a link or save a contact, it asks. The host checks the nonce, then checks the action against its own allowlist (mailto:, tel:, map links and a few others), and either runs it or drops it.
One more real bug: a race in the handshake. If the frame's ready message arrived before the host attached its listener, the card never rendered. The fix was to make the handshake idempotent: the host re-sends render whenever it sees ready, and the frame ignores duplicates.
Checklist for running AI-generated code
- Treat model output as untrusted input, every time.
- Put the rules in one package that every renderer shares.
- Validate the syntax tree against an allowlist. Never blocklist strings.
- Turn every bypass you find into a named validator and a regression test.
- Cap repairs at one attempt, and refund when you give up.
- Render on a separate origin with the minimum sandbox flags, and let the host approve every side effect.
The Krofile case study shows the cards this pipeline renders, with a live example.