[]previous and next page

Security

What ShadowJS escapes, blocks and validates for you, what stays your responsibility, and how to handle CSP, environment variables and tokens.

For agentsUpdated

After this page you can tell which values are safe to render, which attributes the runtime refuses to write, where the framework stops protecting you (innerHTML, iframe sources, your own URLs), and how to configure CSP, environment variables and authentication tokens around a ShadowJS app.

The defaults are safe by construction: the compiler and the runtime treat every dynamic value as data, never as markup, and there is no eval or new Function in the packages. Security problems in a ShadowJS app come from places where you opt out (innerHTML), from data the framework cannot judge (a URL you build), and from the server and browser configuration around it.

Text is text#

A dynamic child becomes a DOM Text node. It is never parsed as HTML, on the client or on the server:

src/comment.tsxtsx
export function Comment(props: { body: string; author: string }) {
  return (
    <article title={props.author}>
      <p>{props.body}</p>
    </article>
  );
}

The server renderer escapes & < > " ' in text and in attribute values, so the same component is safe in HTML strings:

comment-ssr.tsxtsx
import { renderToString } from "@shadow-js/web/server";
import { Comment } from "./src/comment";

const html = renderToString(() => (
  <Comment body={`<img src=x onerror="alert(1)">`} author={`" onmouseover="alert(3)`} />
));
console.log(html.replace(/ data-hk="[^"]*"/g, "").replace(/<!--\[-->|<!--\]-->/g, ""));
Expected output
<article title=

Values that are not text, numbers, booleans, null, arrays or accessors are not rendered: the server throws a TypeError (UNRECOGNIZED_INSERT_VALUE) in development for a plain object, a symbol always throws, and in production an object renders nothing. A raw string is never treated as trusted markup; the only trusted markup on the server is an SsrNode produced by the compiler's helpers or by ssrRaw, which you call only on markup you wrote.

URL attributes#

href, src, action, formaction, xlink:href, srcdoc, poster and data are checked whenever a dynamic value is written. After removing control characters and surrounding whitespace and lower-casing, a value that starts with javascript:, vbscript: or data:text/html is not written. On the client the runtime warns (UNSAFE_URL_BLOCKED, once per page load in development); the server omits the attribute silently.

src/profile-link.tsxtsx
export function ProfileLink(props: { url: string }) {
  return <a href={props.url}>profile</a>;
}

What this does and does not cover:

  • It checks dynamic values only. A literal href="javascript:alert(1)" in your own source is written as you typed it; you wrote it.
  • It checks three prefixes. data:text/html is blocked, but data:image/svg+xml and other data: types are not, and the check does not know which element receives the URL. Do not put untrusted values in src of <iframe>, <embed> or <object>, or in data of <object>, and validate any URL that loads active content against an allow-list of origins.
  • It checks only the attributes listed. srcset, ping, background and CSS url(...) values in styles are not inspected.
  • Parse, don't filter. For user-supplied links, parse with new URL(value, base) and accept only http:, https: and mailto: (and tel:), as the router does for its own navigation targets.
  • Head tags (useHead, Title, Meta, Link) go through the same URL check, and script and noscript cannot be registered through them.

Attribute names, events and spread#

Spread (<div {...props} />) is the one place where a name comes from data. The runtime validates every attribute name against an XML-name subset and refuses anything else, on both targets, so a key cannot add attributes or break out of the tag. Handlers must be functions: a string is never evaluated.

A literal string handler (<button onClick="alert(1)">) is a compile error (STRING_EVENT_HANDLER). <Dynamic component={tag}> accepts a tag name only if it matches ^[a-z][a-z0-9-]*$ (any case), and refuses script; anything else throws TypeError: ShadowJS Dynamic: invalid tag. A data-driven tag cannot inject attributes or markup:

The server enforces the same rules (ssrElement validates the tag and every attribute name, drops on* names, and ssrAttribute applies the URL check).

innerHTML#

innerHTML={value} assigns the string as markup. It is the one escape hatch from the rules above, and the runtime does not sanitize, warn about, or restrict it. On the server the value is written raw as the element's children.

src/raw.tsxtsx
export function Rich(props: { html: string }) {
  // props.html must already be sanitized by the caller; this assignment parses it as markup.
  return <div innerHTML={props.html} />;
}

A branded trusted() wrapper (a type that only sanitized strings can have) is not implemented. There is no SafeHtml type and no built-in sanitizer. Rules:

  1. Prefer structure over strings: render <For>/<Show> from data instead of building HTML.
  2. When you must render user HTML (a CMS body, Markdown output), sanitize it with a maintained sanitizer before it reaches innerHTML, on the server where possible, with an allow-list of elements and attributes. Do not write your own with regular expressions.
  3. Keep the sanitizer next to the innerHTML call so a reviewer sees both together.
  4. Combine with a CSP that forbids inline script, so a sanitizer miss is not an execution.

textContent={value} assigns text and is always safe.

Router navigation targets pass isSafeNavigationTarget (@shadow-js/router/core): relative references and http, https, mailto and tel URLs are allowed; javascript:, data:, blob:, file:, any other scheme, embedded control characters and protocol-relative //host forms are refused. <A> renders other schemes as no link at all, and router.navigate resolves to an "error" outcome for them and for any absolute URL that is not a path of the app. This matters for the common redirect-after-login pattern (/login?next=...): a hostile next cannot send the user to another origin.

Still check authorization on the server for every protected resource; a client redirect is navigation, not access control. Search parameters are parsed by a codec that drops __proto__, constructor and prototype, builds null-prototype objects, caps the query at maxSearchLength (4096) and limits JSON nesting to 16 levels, so a crafted URL cannot pollute prototypes or exhaust the parser.

Prototype pollution#

Object parsers reject keys that could mutate an object’s prototype:

pollution.tsts
import { parseSearch } from "@shadow-js/router/core";
import { deserialize, serialize } from "@shadow-js/web/server";

const search = parseSearch("?__proto__[polluted]=1&constructor=x&ok=1");
console.log(Object.hasOwn(search, "__proto__") || Object.hasOwn(search, "constructor"));
console.log(Reflect.get({}, "polluted") !== undefined);

const payload = JSON.parse('{"__proto__": {"polluted": true}, "x": 1}') as object;
const back = deserialize(serialize(payload)) as Record<string, unknown>;
console.log(Object.getPrototypeOf(back) === null);
console.log(Object.keys(back).join(","));
Expected output
false
false
true
__proto__,x

useStore refuses __proto__, constructor and prototype as path steps or assigned keys, and spread ignores them. The parts you write are yours: when you merge request data into objects with Object.assign, spreading or a recursive merge, you are outside these protections.

Serialized data#

Resource data and router hydration data travel in <script type="application/json"> blocks. serialize() and serializeHydrationData() write <, >, &, U+2028 and U+2029 as \uXXXX escapes so no value can close the element or start a comment, and the readers never evaluate anything. See SSR, hydration and streaming. The data is public to everyone who can load the page: do not serialize secrets, tokens, other users' records or fields the viewer may not see. Validate deserialized data the way you validate any input.

Content Security Policy#

The client runtime needs no inline scripts, no eval and no new Function, so a strict script-src 'self' (or a nonce policy) works for a built app. What else to know:

  • Streaming SSR (renderToStream) emits one inline <script> for the swap runtime and one per boundary. Pass nonce and use the same value in your script-src 'nonce-...' header. The nonce is restricted to base64 and URL-safe characters before it is written. The JSON data blocks are not executable and need no nonce. renderToString and renderToStringAsync emit no scripts.
  • Inline styles. Static style="..." attributes are part of the compiled markup and the server writes style attributes into HTML; a style-src without 'unsafe-inline' blocks style attributes that come from parsed markup. Dynamic styles applied by the client (style={{...}}, style:name) go through the CSSOM, which CSP does not restrict. If you enforce a strict style-src, prefer classes and the style object over style strings.
  • Trusted Types. The DOM runtime compiles templates by assigning markup strings to innerHTML (template() in @shadow-js/web). A page that enforces require-trusted-types-for 'script' needs a default policy that allows those assignments; ShadowJS does not create a policy for you. Head tags refuse script so they cannot bypass a policy.
  • Vite in development adds its own client and HMR scripts; the strict policy is for the production build.
http
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-RANDOM'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Generate a fresh nonce for every response; reuse defeats it.

Environment variables and secrets#

Everything bundled into the browser is public, whatever you call it. Vite exposes to client code only the variables whose names start with envPrefix (default VITE_) through import.meta.env; set envPrefix deliberately and never give a secret that prefix. Keep .env* files with real values out of version control, and keep API keys that must stay secret on a server that your app calls.

@shadow-js/dusk adds environment validation at startup (defineEnv), the DUSK_PUBLIC_ convention (only those variables reach client code; any other import.meta.env read is a build error) and an import-protection build error for server-only modules. With the Vite plugin alone none of that exists: nothing stops a server-only module from being imported into client code, so keep server code in separate entry files and review the bundle for secrets. The server half is described in Dusk: security.

Authentication tokens#

  • Prefer an HttpOnly, Secure, SameSite=Lax (or Strict) session cookie set by your server over a token that JavaScript can read. A token in localStorage is readable by any script that runs on the page, which is exactly what XSS gives an attacker.
  • If you must hold a token in memory, hold it in a signal or store that is not exported to the window, and clear it on sign-out. Do not put it in URLs or in serialized page data.
  • Identity providers wrapped by @shadow-js/clerk and @shadow-js/convex keep tokens inside their SDKs; follow their guides rather than copying tokens into your own storage.
  • State-changing requests need CSRF protection when they authenticate with cookies: SameSite cookies, an Origin check on the server, and a custom request header. @shadow-js/dusk does this for every state-changing request by default; see Dusk: security.
  • Check authorization on the server for every request. Client-side guards (beforeLoad, <Show>) improve the UI; they do not protect data.

Common mistakes#

  • Building HTML strings and assigning them to innerHTML to avoid writing JSX. Use JSX.
  • Trusting UNSAFE_URL_BLOCKED as a URL policy. It stops script URLs, nothing more. Allow-list the schemes and origins your app uses.
  • Passing user input to iframe src. Not covered by the URL check.
  • Serializing whole user objects into page data for convenience.
  • Secrets in VITE_ variables. They are public.
  • Using a client redirect as authorization.
  • Logging full tokens or request bodies in server renders.

See also#