Security
What ShadowJS escapes, blocks and validates for you, what stays your responsibility, and how to handle CSP, environment variables and tokens.
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:
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:
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, ""));
<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.
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/htmlis blocked, butdata:image/svg+xmland otherdata:types are not, and the check does not know which element receives the URL. Do not put untrusted values insrcof<iframe>,<embed>or<object>, or indataof<object>, and validate any URL that loads active content against an allow-list of origins. - It checks only the attributes listed.
srcset,ping,backgroundand CSSurl(...)values in styles are not inspected. - Parse, don't filter. For user-supplied links, parse with
new URL(value, base)and accept onlyhttp:,https:andmailto:(andtel:), as the router does for its own navigation targets. - Head tags (
useHead,Title,Meta,Link) go through the same URL check, andscriptandnoscriptcannot 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.
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:
- Prefer structure over strings: render
<For>/<Show>from data instead of building HTML. - 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. - Keep the sanitizer next to the
innerHTMLcall so a reviewer sees both together. - Combine with a CSP that forbids inline script, so a sanitizer miss is not an execution.
textContent={value} assigns text and is always safe.
Navigation targets#
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:
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(","));
false
false
true
__proto__,xuseStore 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. Passnonceand use the same value in yourscript-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.renderToStringandrenderToStringAsyncemit no scripts. - Inline styles. Static
style="..."attributes are part of the compiled markup and the server writesstyleattributes into HTML; astyle-srcwithout'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 strictstyle-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 enforcesrequire-trusted-types-for 'script'needs adefaultpolicy that allows those assignments; ShadowJS does not create a policy for you. Head tags refusescriptso they cannot bypass a policy. - Vite in development adds its own client and HMR scripts; the strict policy is for the production build.
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(orStrict) session cookie set by your server over a token that JavaScript can read. A token inlocalStorageis 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/clerkand@shadow-js/convexkeep 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:
SameSitecookies, anOrigincheck on the server, and a custom request header.@shadow-js/duskdoes 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
innerHTMLto avoid writing JSX. Use JSX. - Trusting
UNSAFE_URL_BLOCKEDas 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#
- SSR, hydration and streaming for the escaping and serialization rules of the server.
- Routing for
beforeLoadguards and navigation targets. - Diagnostics for
UNSAFE_URL_BLOCKED,INVALID_ATTRIBUTE_NAMEand the other codes.