Back to blog

Your AI Tool Just Gave You an XSS Vulnerability. Here's How to Find It.

I scanned a friend's AI-built app and found 12 innerHTML XSS sinks on the homepage alone. Here's how to tell the dangerous patterns from false positives — and three fixes that actually work.

June 15, 20266 min read
innerhtml
xss
dompurify
javascript-security

Header image

Last Tuesday, a friend sent me his new dashboard. Built with Cursor in an afternoon. Looked sharp — realtime data, clean UI, the kind of thing that makes you think "maybe I should learn to code."

I ran it through VibeShield. Twelve findings on the homepage alone. Every single one was innerHTML fed with untrusted data. URL parameters. Form inputs. API responses. All getting dumped straight into the DOM.

He'd shipped a phishing kit without knowing it.

Why Your AI Tool Does This

AI coding tools don't understand trust boundaries. They've been trained on millions of code examples from tutorials, documentation, and Stack Overflow — and innerHTML is everywhere in that training data. It's the easiest way to put content on a page. It works. It renders. The model doesn't know whether the string you're passing came from a trusted API or from window.location.search.

The 2025 scan of 5,600 vibe-coded apps found over 2,000 high-impact vulnerabilities. A significant chunk were DOM-based XSS — cross-site scripting that executes in your user's browser, using your domain, with access to their session, their cookies, and their data.

Here's what makes it feel personal: your user logs in, trusts your app, and then your app hands their session to an attacker because of five characters you never wrote. The AI wrote them. You shipped them.

Dangerous vs. False Positive

Not every innerHTML is a vulnerability. The question is always the same: where does the data come from?

Dangerous: Assigning user-supplied data to innerHTML. URL parameters (window.location.search), form inputs, API responses containing user-generated content, localStorage values, postMessage event data. If an attacker can control what goes into that string, they can inject HTML.

False positive (generally safe): Assigning static strings, translation function results (like t('welcome_message')), or hardcoded template literals with no user input. If the string is determined entirely at build time, there's nothing to exploit.

The line gets blurry with things like localStorage — yes, localStorage data is "user-supplied" in the sense that the user's browser stores it, but if you wrote it there yourself and never reflect URL parameters into it, it's probably fine. When in doubt, treat it as dangerous.

Three Fixes (Pick the Right One)

Fix 1: textContent — When You Only Need Text

This is the fix for most cases. If you're displaying a username, a comment, an email subject — anything that's just text — use textContent:

// ❌ Vulnerable document.getElementById('output').innerHTML = userName; // ✅ Safe document.getElementById('output').textContent = userName;

Figure 1: textContent vs innerHTML — textContent treats everything as plain text and never executes code.

textContent treats everything as plain text. Even if userName is <script>alert('xss')</script>, it renders as literal characters on the page. It will never execute. It will never fetch an external resource. It's text, period.

This is the right fix about 80% of the time. Most things you put on a page are just text.

Fix 2: DOMPurify — When You Need HTML

Sometimes you genuinely need to render HTML — a rich text editor output, a formatted description, Markdown converted to HTML. In those cases, sanitize:

import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'ul', 'ol', 'li'], ALLOWED_ATTR: ['href', 'target'] }); document.getElementById('content').innerHTML = clean;

Figure 2: DOMPurify with an explicit allow-list — strips <script> tags, event handlers, and javascript: URLs while preserving safe HTML.

DOMPurify strips <script> tags, inline event handlers (onclick, onerror), javascript: URLs, and anything else that could execute code. The key is to use an explicit allow-list: specify exactly which tags and attributes you accept, and everything else gets stripped.

Don't use a deny-list. Attackers are more creative than your deny-list.

Fix 3: DOM API — When You Want Provable Safety

This is the most verbose option, but it's provably safe. You can't inject a script tag through createElement or textContent:

// ❌ String building — attacker controls `name` and `message` element.innerHTML = `<div><strong>${name}</strong>: ${message}</div>`; // ✅ DOM API — no injection surface const div = document.createElement('div'); const strong = document.createElement('strong'); strong.textContent = name; const text = document.createTextNode(`: ${message}`); div.append(strong, text);

Figure 3: DOM API approach — createElement and textContent are provably safe with zero injection surface.

The DOM API doesn't parse HTML strings. It creates nodes. textContent on a text node is always text. There is no path from user input to script execution.

This pattern is especially useful in framework components. In React, instead of dangerouslySetInnerHTML, use JSX with variables in curly braces — React escapes them automatically. In Vue, use v-text or template interpolation instead of v-html.

The Habit That Saves You

Every time you see innerHTML in AI-generated code — or dangerouslySetInnerHTML, or v-html, or insertAdjacentHTML — stop and ask one question: "Where does this data come from?"

If the answer involves users, URL parameters, form fields, API responses, or anything external, switch to one of the three fixes above. It takes thirty seconds. It eliminates an entire class of vulnerability.

Make this a reflex. Your users are trusting you with their sessions. Don't hand those sessions to someone else because of five characters an AI wrote.

ApproachSafetyWorks with HTMLPerformanceBest for
textContentComplete — no HTML parsingNoInstantPlain text display
DOMPurifyHigh — allow-list sanitizationYes~0.5ms per callRich content, Markdown
DOM APIComplete — no HTML parsingManual onlyFast if batchedComplex dynamic UIs

Figure 4: Comparison of the three innerHTML replacement approaches across safety, HTML support, and performance.


Summary

Found this useful? Scan your app at vibeshield.org — free, no account needed. The scanner finds innerHTML sinks, dangerouslySetInnerHTML, eval(), and inline event handlers in one pass.

Read next: We Built a Security Scanner in a Weekend for how the scanner catches these patterns. Or Your App Scored 48/100 to understand what your security score means.