Learn how to stop DOM-based XSS by securing your client-side sinks and sources. Master practical input sanitization and secure coding techniques today.
During a recent security audit of a React-based dashboard, I watched a colleague trigger an alert box just by clicking a carefully crafted URL. It wasn't a server-side flaw; it was a classic case of DOM-based XSS. We were taking a parameter from the URL fragment and passing it directly into an .innerHTML call, effectively handing the keys to the browser's execution engine to any attacker who could trick a user into clicking a link.
It’s a subtle but dangerous vulnerability because the server never even sees the malicious payload. The entire attack happens in the victim's browser. If you’re building modern SPAs, understanding how data flows from sources to sinks is the most critical skill you can develop to ensure your client-side security is actually effective.
To prevent DOM-based XSS, you have to think like a data-flow analyzer. You are looking for a path from a "source" (where untrusted data enters your app) to a "sink" (where that data is executed or rendered).
Common sources include:
location.searchlocation.hashdocument.referrerwindow.nameThe sinks are the dangerous functions that can execute JavaScript or render HTML:
.innerHTML.outerHTMLdocument.write()eval()setTimeout() (when passed a string instead of a function)If you’re pulling data from a URL parameter and shoving it into element.innerHTML, you have a vulnerability. It’s that simple, and it’s that dangerous.
We once tried to sanitize input using a basic regex that stripped out <script> tags. It felt clever at the time. However, an attacker can easily bypass this with <img src=x onerror=alert(1)> or other variations that don't rely on classic script tags.
We learned the hard way that blacklisting is a losing game. You will never catch every possible bypass. Instead, you need a robust strategy for secure coding that focuses on context-aware output encoding or, better yet, avoiding dangerous sinks entirely.
If you want to stop DOM-based XSS in its tracks, follow these three rules.
Always use textContent or innerText instead of innerHTML. These properties treat the input as literal text, meaning the browser won't attempt to parse it as HTML.
JAVASCRIPT// Dangerous const userBio = new URLSearchParams(window.location.search).get(CE9178">'bio'); document.getElementById(CE9178">'bio-container').innerHTML = userBio; // Secure const userBio = new URLSearchParams(window.location.search).get(CE9178">'bio'); document.getElementById(CE9178">'bio-container').textContent = userBio;
Sometimes you must render HTML from a user-provided string—maybe for a rich text editor. In that case, do not roll your own regex. Use a battle-tested library like DOMPurify. It’s the industry standard for stripping dangerous elements while keeping the safe ones.
JAVASCRIPTimport DOMPurify from CE9178">'dompurify'; const dirtyHtml = getFromUrl(); const cleanHtml = DOMPurify.sanitize(dirtyHtml); document.getElementById(CE9178">'content').innerHTML = cleanHtml;
A strong CSP is your last line of defense. By setting a Content-Security-Policy header, you can restrict where scripts can be loaded from and prevent the execution of inline scripts entirely. Even if you miss a vulnerability in your code, a strict CSP can prevent an attacker from successfully executing their payload.
Beyond just the DOM, remember that security is holistic. If you're handling data that eventually hits your backend, make sure you aren't creating other vulnerabilities. For example, preventing mass assignment vulnerabilities with DTOs in Laravel and Express is just as important as sanitizing your client-side inputs.
If you’re working with legacy codebases or complex forms, you might also want to revisit XSS prevention strategies: A guide for modern web developers to ensure you're covered across all three XSS variants: Reflected, Stored, and DOM-based.
Q: Is innerHTML ever safe?
A: Only if you are 100% sure the content is hardcoded in your source files or has been passed through a library like DOMPurify. If it touches user-controlled input, assume it’s unsafe.
Q: Does React/Vue/Angular protect me automatically?
A: Mostly, yes. Frameworks like React sanitize data by default when you use curly braces {data}. However, if you explicitly use dangerouslySetInnerHTML in React or v-html in Vue, you are opting out of that protection and effectively creating a potential sink.
Q: What about eval()?
A: Just don't use it. There is almost no scenario in modern application development where eval() is the right tool. It’s a massive security risk and a performance bottleneck.
Security isn't a feature you toggle on; it's a discipline. I'm still wary every time I see a developer reach for a dynamic sink. We've come a long way from the days of simple alert boxes, but the core issue—trusting data that comes from the user—remains the same. Keep your sinks narrow, your sources isolated, and always favor built-in browser protections over custom sanitization logic.
Next time, I'm planning to look into how we can automate these checks during the CI/CD pipeline, because manual code review is great, but catching these patterns before they hit production is even better.
BOLA vulnerabilities can expose private data in multi-tenant apps. Learn how to secure your API endpoints by decoupling authorization from your business logic.