DOM XSS, explained
The injection happens after your server is done, so no server-side fix touches it. Sources and sinks, the jQuery CVEs scanners keep flagging, and Trusted Types.
- security
- vibe coding
- engineering
DOM-based cross-site scripting is the variant where the injection happens after
your server is finished. The page it served was fine. The JavaScript running on
that page took something out of the URL, or out of postMessage, or out of
localStorage, and wrote it into the document as markup.
The defining property, and the reason it needs its own post, is that the payload
does not have to pass through your backend, and in the worst case it never does.
A fragment after # is never sent to the server. It does not appear in your
access logs, your WAF does not see it, your framework’s autoescaping never runs
on it, and a scan of your response bodies finds nothing.
Amit Klein named the class in July 2005 in DOM Based Cross Site Scripting or XSS of the Third Kind, and the paper’s framing is still the one to use: the vulnerability is a data flow inside the client, from a source the attacker controls to a sink that interprets what it is given.
Sources and sinks
A source is anywhere the page reads attacker-influenceable data:
location.href,location.search,location.hash,document.URLdocument.referrerwindow.namepostMessageevent datalocalStorage,sessionStorage, cookies, IndexedDB- Any API response, which includes your own, since a stored value passes through
A sink is anywhere a string becomes markup or code:
element.innerHTML,outerHTML,insertAdjacentHTMLdocument.write,document.writelneval,new Function,setTimeout("string"),setInterval("string")element.setAttribute("href", ...)and directhref/srcassignment, wherejavascript:is the payload- jQuery’s
.html(),.append(),.after()and$(...)itself when given markup - React’s
dangerouslySetInnerHTML, Vue’sv-html, Svelte’s{@html}
Every DOM XSS is one line connecting a member of the first list to a member of the second. That is a small enough definition that you can audit for it directly, which is the good news in an otherwise awkward class.
// The canonical one liner. Source: location.hash. Sink: innerHTML.
document.querySelector("#tab").innerHTML = location.hash.slice(1);
Load that page with #<img src=x onerror=alert(1)> and the script runs. The
server saw a request for the page and nothing else.
The shapes in a modern generated app
This is the XSS variant that AI builders are structurally most exposed to, and the reason is architectural rather than careless. What Lovable and Bolt produce is a Vite React single page app talking straight to Supabase: routing happens in the browser, query parameters are parsed in the browser, and there is frequently no server of yours in the request path at all. Every server-side control discussed in the other two XSS posts is therefore absent by construction, not by oversight.
React escapes text, which removes the classic sink for most of your UI, and then these five survive.
The deliberate escape hatch. dangerouslySetInnerHTML fed from a value that
originated in the URL. Common in “render the campaign message from the query
string” features.
A URL assembled from a parameter. This one is easy to miss because it looks like data, not code:
// Vulnerable. `next` can be javascript:alert(document.cookie).
<a href={new URLSearchParams(location.search).get("next")}>Continue</a>
React does not escape URL schemes for you. It renders the attribute as given. The fix is a scheme allowlist, the same one that closes open redirect on the server side, which is why those two findings so often appear on the same line of code.
A ref reaching around the framework. Somebody needed to inject third-party
markup, so they wrote ref.current.innerHTML = html and React’s escaping is
simply not in the path any more.
A postMessage listener with no origin check. This is the one that survives
in embedded widgets, checkout iframes and OAuth popups:
// Vulnerable twice: any origin can send this, and the payload is rendered as markup.
window.addEventListener("message", (event) => {
document.getElementById("panel").innerHTML = event.data.html;
});
The listener has to verify event.origin against an exact allowlist before it
looks at event.data. An origin check that uses startsWith or includes
fails to https://yoursite.com.evil.example, the same string-matching mistake
that produces CORS misconfiguration.
A dependency. This is the category people forget is theirs.
CVE-2020-11022 and
CVE-2020-11023 are DOM XSS in
jQuery itself. Both advisories carry the same unsettling phrase: HTML from an
untrusted source passed to .html(), .append() and friends may execute
untrusted code even after sanitizing it, because htmlPrefilter rewrote the
markup after your sanitizer had approved it. The first covers the general case
and was fixed in 3.5.0; the second covers markup containing <option> elements
and needed 3.5.1. Both are still among the most common findings in real
dependency scans, because the version pinned in a template from 2019 is the
version still there.
That pair is a good illustration of what the layers do. Our SCA engines,
OSV-Scanner and Grype, report the jQuery version as vulnerable from the
lockfile. Our SAST engine, OpenGrep, flags the .html() call site. Neither one
knows whether the two meet, and the answer is usually “yes, in exactly one
place”, which is why the five layers
are worth reading together instead of as five separate reports.
The fixes, from cheapest to strongest
Use the text API. Most DOM XSS is innerHTML used where textContent was
meant. textContent cannot produce an element, ever, and it is also faster:
document.querySelector("#tab").textContent = decodeURIComponent(location.hash.slice(1));
Build nodes instead of strings. When the output genuinely has structure,
createElement plus textContent for the parts that came from outside gives
you the markup you wanted with no parser involved.
Allowlist URL schemes before assigning to href or src.
const SAFE_SCHEMES = ["http:", "https:", "mailto:"];
export function safeUrl(raw, fallback = "/") {
try {
const url = new URL(raw, window.location.origin);
return SAFE_SCHEMES.includes(url.protocol) ? url.href : fallback;
} catch {
return fallback;
}
}
Sanitize when you truly need HTML. DOMPurify with an explicit allowlist, the
same configuration your server uses, exported from one module so the two cannot
drift. See stored XSS for the configuration and
the reason USE_PROFILES: { html: true } belongs in it.
Turn on Trusted Types.
Trusted Types
is the only control here that is structural rather than diligent. With
require-trusted-types-for 'script' in your CSP, the browser refuses to let a
plain string be assigned to a dangerous sink at all. Every innerHTML = with a
raw string throws, and the only way through is a policy you defined:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify
// The one place in the app allowed to produce markup.
window.trustedTypes?.createPolicy("dompurify", {
createHTML: (input) => DOMPurify.sanitize(input, { USE_PROFILES: { html: true } }),
});
The advice here changed recently, so ignore any guide older than this year that
calls it Chrome-only. Chrome and Edge have had it since 2020, Safari added it in
version 26, and Firefox followed in February 2026, which is when MDN began
listing the API as
Baseline.
Two caveats remain and neither is about browser share. Older browser versions
simply do not enforce it, so this is a hard stop for current browsers rather
than a guarantee for every visitor, and it will break third-party scripts that
assign to sinks. Roll it out with Content-Security-Policy-Report-Only first
and read the violation reports: that report set is, by itself, the most complete
inventory of your sinks you can get without reading every file.
Finding it in your own code
Static analysis genuinely helps here, more than it does with stored XSS, because both ends of the flow are in the same file often enough to be matched. Start with the sinks, since there are fewer of them than there are sources:
grep -rnE "innerHTML|outerHTML|insertAdjacentHTML|document\.write|dangerouslySetInnerHTML|v-html|\{@html" src/
Read every hit and answer one question: could this string have come from the URL, from a message event, or from a field somebody else can write. Most will be a constant or a template you control, and the two or three that are not are your finding.
Then do it in the browser, because the interesting version is at runtime. DevTools, sources panel, and a breakpoint on the sink. Chromium can also break on DOM modification, which is the fastest route from “the payload fired” to “the line that fired it”.
What this class teaches about the rest
DOM XSS is the clearest case of a general rule: a control only sees the layer it runs on. A WAF inspects requests, and the fragment is not in the request. Server templates escape server output, and this markup is written after that output was parsed. Dependency scanning knows your jQuery is old but not whether you call the vulnerable method. Each one is correct about its layer and blind past it.
The practical consequence is small and specific. Keep a list of your sinks, because it is short, review changes to it the way you would review changes to an auth check, and let the browser enforce what you can with Trusted Types. The alternative is trusting that no future feature will ever assign a string it did not write, which is a promise nobody has kept since 2005.