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.

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.
| Approach | Safety | Works with HTML | Performance | Best for |
|---|---|---|---|---|
textContent | Complete — no HTML parsing | No | Instant | Plain text display |
| DOMPurify | High — allow-list sanitization | Yes | ~0.5ms per call | Rich content, Markdown |
| DOM API | Complete — no HTML parsing | Manual only | Fast if batched | Complex dynamic UIs |
Figure 4: Comparison of the three innerHTML replacement approaches across safety, HTML support, and performance.
Summary
- AI coding tools don't understand trust boundaries — they've been trained on millions of code examples where
innerHTMLis the easiest way to put content on a page, regardless of where that content comes from. - Pick the right fix:
textContenthandles ~80% of cases when you only need text.DOMPurifywith an explicit allow-list works when you genuinely need HTML. The DOM API (createElement+textContent) is provably safe with zero injection surface. - The one-question habit that saves you: every time you see
innerHTML,dangerouslySetInnerHTML,v-html, orinsertAdjacentHTML, ask "where does this data come from?" If the answer involves users, URL parameters, or API responses, switch to a safe alternative. - Not every
innerHTMLis a vulnerability — static strings and build-time constants are generally safe. The line blurs withlocalStorageand API responses; when in doubt, treat it as dangerous.
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.