Read this before you deploy. The first rule is the one that matters.
The model in one paragraph#
next-live runs code with new Function on your page. That code has the page's full authority: cookies, storage, DOM, and your APIs as the signed-in user.
Safe when snippet authors are people you trust. Unsafe when they are not.
Who can write vs who can view#
Trusted author
Writes / saves snippet
Admin, internal dev, your DB write API
Your API
Returns source string
Authenticated, audited, versioned
LiveProvider
Compiles + runs on page
Same origin, same cookies as the host
Visitor
Views preview
Cannot inject code unless they control the write path
| Role | Can do | Your control |
|---|---|---|
| Snippet author (admin, internal dev) | Save TSX that runs on your origin | Strict auth on the write API; audit trail; version history |
| Visitor | View the compiled preview | Cannot inject code unless they also control the write path |
| Attacker with URL param access | Run arbitrary JS on your site | Block this. Never pass user-controlled strings to code |
Whoever can save a snippet can run JavaScript on your site
Treat a row in your snippets table like a production deploy. That endpoint deserves your strictest authorization check.
Rule 1: Feed the evaluator only from your API#
// ✅ from your authenticated APIconst { source } = await fetch(`/api/apps/${id}`).then((r) => r.json());// ❌ neverconst source = new URLSearchParams(location.search).get('code');
If an attacker controls what reaches code, no CSP setting prevents execution. That is by design.
Rule 2: Scope unsafe-eval to runner routes#
new Function requires 'unsafe-eval' in script-src. It does not have to apply to your whole app:
// Shared list: proxy.ts and RunnerLink must agree.export const RUNNER_ROUTES = ['/playground', '/apps', '/docs'] as const;export function isRunnerRoute(pathname: string): boolean {return RUNNER_ROUTES.some((route) => pathname === route || pathname.startsWith(`${route}/`),);}// proxy.ts: grant 'unsafe-eval' only when isRunnerRoute(pathname)// RunnerLink: use <a> (real navigation) for runner destinations
Per-route CSP and client-side routing do not compose. Soft-navigating from / into /docs keeps the landing policy without `'unsafe-eval'`. Use `RunnerLink` for cross-boundary links. Working copy: apps/playground/proxy.ts. Visit /rsc-check to see CSP blocking eval.
unsafe-eval is narrower than it sounds
It gates string-to-code APIs only (eval, Function). It does not allow loading external scripts, so script-src 'self' 'unsafe-eval' still blocks attacker-hosted code.
The policy also carries 'unsafe-inline' in script-src, because Next injects
inline bootstrap and streaming scripts. It is the one real weakening in an
otherwise strict policy, worth replacing with a nonce on your non-runner
routes, though it buys nothing on the runner routes, where 'unsafe-eval' is
already the stronger capability. A nonce-based CSP also forces fully dynamic
rendering, which is a genuine cost to weigh.
What is contained (and what is not)#
Contained: runtime errors, render loops. <LiveErrorBoundary> keeps a broken snippet from crashing the host. A render-rate breaker stops runaway setState.
Not contained: full page authority. A synchronous while (true) hangs the tab. window, fetch, and the DOM are always available. The module registry limits convenience, not capability.
If authors stop being trusted#
Same-realm evaluation is for cooperative authors, not a security boundary. Public marketplaces or cross-tenant code need an iframe on a separate origin, a different product with different trade-offs (no live props by reference).
Checklist before production#
- Snippet source only from authenticated write API
- Audit log + version history on stored snippets
-
'unsafe-eval'scoped to runner routes inproxy.ts - Strict
connect-src: snippets cannot exfiltrate to arbitrary hosts -
object-src 'none'andbase-uri 'self'set, they close known bypasses and cost nothing -
upgrade-insecure-requestson in production (gated onNODE_ENVso localhost still works) -
RunnerLinkused for every link that crosses into a runner route -
validateSnippetsin CI (Validating in CI)
