Closed-Mode Shadow DOM: The Exception to 'Always Use Open'

July 2, 2026

Closed-Mode Shadow DOM: The Exception to "Always Use Open"

In "Shadow DOM Explained," I said mode: "closed" doesn't make anything more secure and that almost everyone should reach for open. I still believe that's the right default. But while building the embeddable chat widget for this project, I deliberately shipped closed mode, and it's worth being precise about why — because the reasoning is narrower than "closed is more secure," and I don't want the exception to read as a quiet reversal of the original advice.

The actual code

From widget/src/ui.ts, the function that builds the widget's DOM when it's injected into a host page:

export function buildWidget(config: WidgetConfig, apiKey: string): void { const host = document.createElement("div"); host.id = "rag-widget-host"; host.style.cssText = "position: fixed; bottom: 24px; right: 24px; z-index: 99999;"; document.body.appendChild(host); const shadow = host.attachShadow({ mode: "closed" }); // ...bubble button, iframe, styles all get appended to `shadow` }

The dev log entry for this from CAN-32 is blunt about the intent:

Shadow DOM mode is 'closed' (not 'open') — deliberate: the host page's own JS shouldn't be able to reach widget internals via element.shadowRoot.

buildWidget() runs on DOMContentLoaded inside a <script> tag some customer drops into their own site. That's the whole context for this decision: the widget author (me) does not control, and cannot audit, anything else running on that page.

Why "not more secure" is still true here

Everything the original post says about closed mode still holds. host.shadowRoot returns null from outside, but the widget's own closure holds the real reference (shadow, right there in buildWidget) and uses it freely to append the iframe and bubble. Nothing about closed mode hides the rendered content — open devtools, check "show all shadow roots," and the widget's markup is sitting right there like any other element. A host page that actually wants in has options: monkey-patch Element.prototype.attachShadow before the widget script loads and capture the reference at creation time, walk the accessibility tree, or just read network requests to see what the iframe is doing. Closed mode stops none of that. If I were describing this to someone evaluating it as a security boundary, the honest answer is "it isn't one."

What it does buy: raising the casual-access bar

The thing closed mode actually removes is element.shadowRoot being trivially walkable. On a widget author's own site, that's a non-issue. On an arbitrary customer's site, it's a live one — the widget shares the page with whatever else that customer has installed: other vendors' scripts, browser extensions doing generic DOM instrumentation, the customer's own analytics or A/B-testing code that blanket-querySelectorAll('*')s the document, and so on. None of that is a targeted attacker. It's ordinary, well-meaning, messy JavaScript that happens to walk into .shadowRoot because it's open and reachable, then reads or mutates whatever it finds there — because it can, not because it's trying to.

Closed mode means document.getElementById('rag-widget-host').shadowRoot is null for all of that code, with zero effort on my part. It's not a wall against someone who specifically wants in. It's the absence of an unlocked door for everyone who wasn't looking for one.

The cost is real, and it's the same cost from the original post

This isn't a free upgrade. Closed mode breaks the same things it always breaks: devtools' shadow root inspection UX gets clunkier, testing libraries that assume element.shadowRoot works need extra handling, and any third-party tooling on the customer's own site that might have wanted to introspect the widget (accessibility scanners, for instance) can't. For this widget those costs are acceptable — there's no legitimate reason a testing library on the customer's page needs to reach into a chat widget's internals, and I control the widget's own test setup separately. But it's a genuine tradeoff being accepted deliberately, not a strictly-better setting I forgot to mention in the first post.

Where the line is

The distinguishing factor is trust over the rest of the page, not "is this a widget." A component library running inside your own app, where you control every other script on the page, gets nothing from closed mode and pays the full debugging cost for it — that's still squarely the "always use open" case from the original post. The exception is specifically for code that ships onto pages you don't control, alongside other code you didn't write and can't vet, where "don't be trivially walkable by accident" has actual marginal value even though it was never going to be a real security boundary.

Wrap-up

Closed mode didn't stop being cosmetic just because I used it — it's still not a security boundary, and I'd still tell you to use open for basically everything. The exception is narrow: a third-party embeddable widget, sharing a page with code it doesn't trust and can't audit, where removing the casual element.shadowRoot path is worth the debugging cost specifically because the alternative is being walkable by accident, not by attack. That's a real reason. It's just a much smaller reason than "more secure" would be.

GitHub
LinkedIn
youtube