Why AI-generated code keeps shipping XSS, and the four places it hides
Modern frameworks escape output by default, so the model has to be asked to turn that off, and formatting requests are how it gets asked. Rendering Markdown, showing rich text, embedding an SVG or injecting HTML from an API are the four routes, and all four go through one prop that exists to bypass the protection.
Georgetown's Center for Security and Emerging Technology tested code generated by five major models and found cross-site scripting in 86% of the samples. That number is high enough to be worth explaining rather than repeating, because the obvious explanation is wrong.
The obvious explanation is that models do not know about escaping. They do; ask any of them to explain XSS and you will get a correct answer. The problem is that you never ask them to explain XSS. You ask them to render a description with line breaks in it, and there is exactly one easy way to do that.
What makes a framework safe, and what turns it off?
React, Vue, Svelte and Angular all escape interpolated values by default. Put a user's display name in {name} and a <script> tag in it renders as visible text rather than running. That default is the reason XSS became rarer in the 2010s, and it holds unless something deliberately steps around it.
Each framework ships exactly one deliberate escape hatch, and the names are honest:
- React:
dangerouslySetInnerHTML - Vue:
v-html - Angular:
bypassSecurityTrustHtml - Svelte:
{@html ...} - Plain JavaScript:
element.innerHTML
A model reaching for one of these is not confused. It has been asked for something the safe path cannot do, and this is the shortest path that does it.
The four requests that produce it
"Render this Markdown." The user writes a description with bold text and links, you want it formatted, so a Markdown library converts it to an HTML string and something has to inject that string. Most Markdown renderers pass raw HTML straight through unless you configure them not to, so <img src=x onerror=...> in a comment survives the round trip intact.
"Make this a rich text field." Any editor that stores formatted content stores HTML, and displaying it means injecting it. The editor sanitises what it accepts on the way in, which people mistake for protection, but nothing stops an attacker posting to your API directly and skipping the editor entirely.
"Let users upload a profile picture." An SVG is a document that can carry script, and a permissive upload handler that accepts image/* accepts SVG. Serve it from your own origin and the script inside it runs with your site's session.
"Show the description from the API." Content arriving from an external service feels like data rather than user input, so it gets treated as trusted. It is only as trusted as whoever can write into that service, which for most integrations includes your users.
Why does it survive testing?
Because it looks correct. The feature works, the formatting renders, the tests pass, and nothing about the page suggests anything unusual. Stored XSS in particular only affects the person viewing somebody else's content, and while you are building you are always looking at your own.
There is also a category error that makes people relax too early. A great deal of security guidance about XSS predates modern frameworks and reads as historical, so a developer who knows React escapes by default concludes the problem is solved and stops looking for the exception. The exception is the entire remaining risk.
How do I check my own app?
Two passes, one in the code and one in the browser. Neither needs a tool you do not already have open.
In the code, search the whole project for the escape hatches listed above, plus innerHTML and outerHTML. For each hit, trace the value backwards until you reach either a hard-coded string, which is fine, or anything a user or an external service can influence, which is not.
In the browser, pick every field whose contents another person will see: display names, comments, bios, project titles, support ticket bodies. Put <img src=x onerror=alert(1)> in each one, save it, and view it from a second account. That payload is better than the classic <script> tag because it survives more sanitisers and more insertion methods.
Then try the same string in the places people forget. Your filename on an upload, the label on a saved filter, whatever you type into a search box that gets echoed back with "no results for".
How is it actually fixed?
Removing the escape hatch, where you can. If the field only ever holds a name, interpolate it normally and the problem disappears permanently rather than being contained.
Where you genuinely need formatted output, sanitise on render rather than on input. DOMPurify is the standard answer in the browser, and the important part is running it at the moment you inject, not at the moment you save, because sanitising on save leaves every row written before you added it. Configure your Markdown renderer to disable raw HTML too, since that is a single flag in every common library.
For uploads, serve user files from a separate origin or force a download disposition, so an SVG cannot run with your site's session. And put a Content Security Policy in front of all of it as a second layer, one that does not include unsafe-inline, which turns most successful injections into a console error rather than a session theft.
What a browser test cannot reach
Typing a payload into visible fields finds the fields you thought of. It will not find the admin view that renders a user-submitted string, the email template built by string concatenation, or the one component that was written before you added sanitising and never got revisited.
Those need somebody to read the code, and a repository is bigger than an evening. That is what Sentrint does: it reads the whole thing, traces where untrusted values end up, and reports what an attacker would reach first in plain English, with a fix you can paste back into the tool that wrote it. The first scan is free with no card, and it is a code scan rather than a pentest, so it reads what you wrote instead of attacking your running site.
Run the browser test tonight anyway. Six fields and ten minutes will tell you more about your app than the 86% figure does.