DOM-based XSS: How to Detect and Mitigate Vulnerabilities
DOM-based XSS happens when your client-side code executes untrusted data. Learn to detect these vulnerabilities and secure your frontend with modern practices.
During a recent audit of a legacy internal dashboard, I found a classic vulnerability that had been sitting in plain sight for months. A developer had used location.hash to dynamically update a welcome message on the page, assuming that because the data never touched the server, it was "safe." It wasn't. By injecting a payload into the URL fragment, I could execute arbitrary JavaScript in the context of any user who clicked the link.
That’s the core of DOM-based XSS. Unlike reflected or stored XSS, where the server is the vector, this attack happens entirely in the browser. The vulnerability lives in the client-side code, specifically in how it handles data from an untrusted source and passes it to an execution sink.
Identifying the Flow: Sources and Sinks
To stop these attacks, you have to think like an attacker. You need to map out where data enters your application and where it gets executed.
A source is a JavaScript property that the attacker can control. Common examples include:
location.searchlocation.hashdocument.referrerwindow.name
A sink is a function or DOM object that can execute code or render HTML. If you pass data from a source to a sink without proper sanitization, you’ve created a vulnerability. Watch out for these common sinks:
element.innerHTMLdocument.write()eval()setTimeout()(when passed a string)
We previously covered Preventing DOM-based XSS: A Guide for Modern JavaScript Apps, which goes deeper into the specific browser APIs that act as high-risk sinks.
The Wrong Turn: Why "Simple" Sanitization Fails
Early in my career, I tried to mitigate these issues using regex-based filtering. I’d strip out <script> tags whenever a user input field updated the DOM. It felt clever until I realized that an attacker could bypass it with an <img> tag and an onerror attribute.
Regex is the wrong tool for DOM-based XSS because HTML is not a regular language. If you try to parse it, you will eventually lose to an edge case. Instead, focus on secure frontend development by avoiding dangerous sinks entirely.
If you must render user-provided HTML, use a battle-tested library like DOMPurify. It’s the industry standard for a reason.
JAVASCRIPT// The dangerous way const userContent = new URLSearchParams(window.location.search).get(CE9178">'bio'); document.getElementById(CE9178">'profile').innerHTML = userContent; // The secure way import DOMPurify from CE9178">'dompurify'; const cleanContent = DOMPurify.sanitize(userContent); document.getElementById(CE9178">'profile').innerHTML = cleanContent;
Defensive Layers for Client-Side Security
Even with good coding habits, bugs happen. You need a defense-in-depth strategy. If you're building modern interfaces, check out our Security Best Practices in React: Hardening Your Production Apps for framework-specific advice on managing data flow.
Beyond sanitization, implement these two strategies:
- Content Security Policy (CSP): A strong CSP is your last line of defense. By setting
script-src 'self', you prevent the browser from executing any inline scripts or scripts loaded from untrusted domains, even if an attacker manages to inject them into the DOM. - Use Safer APIs: Prefer
textContentorinnerTextoverinnerHTML. These properties treat the input as raw text, making it impossible for the browser to interpret the data as HTML elements.
| Sink Type | Dangerous Property/Method | Safer Alternative |
|---|---|---|
| HTML Rendering | innerHTML | textContent / innerText |
| URL Manipulation | location.href | URL API / <a> tag rel attributes |
| Execution | eval() | JSON.parse() |
| Styles | style.cssText | element.style.property |
Staying Vigilant
When I’m reviewing code, I specifically look for any data flow originating from the URL. It’s the most common source of JavaScript vulnerability reports I see today. If you’re interested in broader mitigation, we’ve also detailed XSS prevention strategies: A guide for modern web developers to help you standardize your approach across different application layers.
One thing I’m still experimenting with is the Trusted Types API. It enforces policies that prevent you from accidentally passing strings to dangerous sinks, forcing you to use trusted objects instead. It’s not supported everywhere yet, but it’s the future of browser-level protection.
Next time you’re building a feature, ask yourself: "Where does this data come from, and what happens if it contains a malicious payload?" If you can’t answer that, start by replacing your innerHTML calls. It’s a small change that saves you hours of post-incident debugging later.
FAQ
What is the difference between DOM-based XSS and Reflected XSS? Reflected XSS involves the server—the payload is sent to the server and reflected back in the response. DOM-based XSS occurs entirely on the client side, meaning the server never even sees the malicious payload.
Are modern frameworks like React or Vue immune to DOM-based XSS? Not entirely. While they automatically escape data inserted into templates, you can still introduce vulnerabilities by using "dangerouslySetInnerHTML" in React or v-html in Vue without proper sanitization.
Can I rely on CSP alone to stop DOM-based XSS? CSP is a powerful deterrent, but it’s not a silver bullet. If your application has a "gadget" (a piece of legitimate code that performs an unsafe action), an attacker might still bypass CSP. Always sanitize your inputs as your primary defense.
