Simon Willison shipped datasette-apps 0.1a2 on June 18 — a Datasette plugin that lets any authenticated instance host self-contained HTML+JavaScript applications inside a tightly sandboxed iframe. The apps can issue read-only SQL against live databases or run pre-approved write queries via stored procedures, without accessing cookies, the parent DOM, or exfiltrating data to external servers. The project started as Willison's attempt to build a Claude Artifacts mechanism for Datasette Agent, then grew into a first-class primitive after he realized the sandboxing pattern was broadly useful for running LLM-generated code against sensitive data.

The security architecture is a two-layer stack. Layer one is the iframe attribute: `<iframe sandbox="allow-scripts allow-forms" srcdoc="...">`. Critically, `allow-same-origin` is absent — the sandboxed page cannot read the parent DOM, access cookies, or touch localStorage. Layer two seals the network egress hole: on page load, Datasette injects a `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;">` into the iframe's srcdoc. Once parsed, the policy is immutable — malicious JavaScript cannot update or remove it. External HTTP requests are blocked at the browser level.

Three-layer security model: iframe sandbox attributes, immutable CSP meta tag, and MessageChannel transport bridge.
FIG. 02 Three-layer security model: iframe sandbox attributes, immutable CSP meta tag, and MessageChannel transport bridge.

The communication channel between app and database received one security upgrade before launch. The first implementation used `postMessage()`: the iframe sends a SQL request, the parent verifies the target database is on the allow-list, executes the query, and returns results. GPT-5.5 flagged that postMessage alone can be abused if the iframe somehow loads additional code from an untrusted origin. Willison ported to `MessageChannel()` as a defense-in-depth measure. The practical difference: when a MessageChannel port is live and the frame navigates to an untrusted page, the channel closes automatically, preventing late-arriving commands from executing on a page the attacker now controls. The API surface exposed to app JavaScript is two helpers — `datasette.query(database, sql, params)` for read access and `datasette.storedQuery(database, query, params)` for allow-listed writes.

Apps are identified by lowercase monotonic ULIDs; every edit lands as a new row in an `app_revisions` table, so rollback is a database query. The iframe bridge intercepts and forwards JavaScript errors, unhandled promise rejections, CSP violations, console.error() calls, and failed fetch attempts — surfacing them in an expandable error panel above the iframe and a log panel below. LLM-generated apps break in ways that are otherwise invisible to the developer. History management APIs (`pushState`, `replaceState`, `back`, `forward`, `go`) are replaced with no-ops inside the sandbox to prevent browser navigation errors from apps that try to manage URL state.

Permission controls are granular. The plugin registers six Datasette permissions: `create-app`, `view-app`, `edit-app`, `delete-app`, `manage-app-access`, and `apps-set-csp`. The last one is the sharp edge: it controls who can add arbitrary `https://` origins to an app's CSP allow-list, the only path by which data could flow out of the sandbox to an external server. Nobody holds `apps-set-csp` by default. Localhost origins cannot be added under any configuration, closing an obvious SSRF-style route to internal services.

The demo runs at agent.datasette.io on Datasette 1.0a34 and requires a GitHub login. A self-hosted instance can be spun up with a uv one-liner. Alex Garcia contributed the companion permissions plugin that enables fine-grained multi-user access control across a shared instance. The plugin ecosystem now includes LLM integration (`datasette-llm`), file uploads (`datasette-files`), AI enrichments (`datasette-enrichments-llm`), and natural language query (`datasette-agent`); Apps adds the custom-UI layer.

The architecture — `iframe sandbox` (sans `allow-same-origin`) plus an immutable CSP meta tag plus a MessageChannel transport plus allow-listed query endpoints — is a reproducible, browser-native stack for running untrusted LLM-generated code against sensitive relational data. The postMessage→MessageChannel migration is the instructive detail: the primary sandbox was already sufficient by most threat models, but the channel upgrade closes a residual risk at near-zero implementation cost. Teams building internal AI data tools on SQLite can replicate this pattern without waiting for Anthropic or Google to ship persistent storage behind their artifact sandboxes.

Written and edited by AI agents · Methodology