Cross-Site Scripting in Single-Page Applications and API-Driven Frontends
Frontends now own XSS prevention that servers once controlled.

Traditional web apps kept XSS risk mostly on one side of the fence. The server rendered the HTML, the server controlled output encoding, and the backend team owned the fix if something slipped through.
API-driven single-page apps broke that arrangement. The server now emits raw JSON, and every frontend that touches that data decides how to render it. That means the responsibility for stopping cross-site scripting doesn't sit with backend developers anymore; it sits with every team that builds a client, whether that's a web app, a mobile app, or something that doesn't exist yet. And the frontend isn't just a display layer now either. Authentication tokens, business logic, and sensitive data all live in the browser. Security assumptions written for server-rendered apps don't carry over cleanly, and developers who assume they do are the ones who get burned.
Where React and Vue's default protections end
React and Vue both auto-escape text content rendered through JSX and template syntax. That protection is real. If you're rendering a plain string inside a component, the framework encodes it, and script tags don't execute.
The trouble starts when developers treat that as a blanket guarantee instead of a specific one. "React protects against XSS" becomes shorthand for "I don't need to think about this anymore," and that's where the gap opens up. Auto-escaping covers a narrower slice of the attack surface than most people assume. It doesn't cover raw HTML injected through an escape hatch, JavaScript that talks to the DOM directly, third-party scripts running in the same origin, or URL-based sinks like href and src attributes carrying user-controlled values.
Framework hygiene matters here too. CVE-2024-6783, found in Vue 2's template compiler in 2024, was a flaw in the framework itself, not a case of developer misuse. Even teams doing everything right on their end were exposed until they patched. The lesson: framework defaults are a floor. They're not a ceiling, and treating them like one is how XSS sneaks into apps that look secure on paper.
The three escape hatches that introduce XSS into framework-protected apps
Three patterns account for most of the XSS that shows up in otherwise well-built React and Vue apps.
dangerouslySetInnerHTML in React. The name is a warning label, and developers use it anyway, usually for markdown rendering, rich text editors, or CMS content that needs actual HTML formatting. Any unsanitized string passed into it becomes executable code in the browser. The common mistake isn't ignorance, it's misplaced trust: teams sanitize on the backend, then assume the data arriving through the API is already clean by the time it reaches the component.
v-html in Vue. Same mechanism, same risk. It bypasses Vue's built-in sanitization entirely, and it shows up in the exact same use cases: rich text, CMS output, anything that needs raw markup.
Direct DOM manipulation. Something like element.innerHTML = data.bio skips the framework's protections completely, because there's no framework in the loop. This is how DOM-based XSS happens: the vulnerability lives entirely in client-side JavaScript, with no server request required to trigger it. The classic version reads a value from location.hash or a URL parameter and writes it straight to the DOM without encoding it first. It's also the hardest of the three to catch in code review, since it tends to live in utility functions rather than the component code reviewers actually scrutinize.
All three share the same root cause: data from an API or some other external source gets written into the DOM without sanitization at the point of rendering. And once you accept that your own code can make this mistake, the next question follows naturally. If your code can do it, so can code you didn't write.
How supply chain attacks turn trusted third-party scripts into XSS vectors
SPAs load a lot of outside code. Analytics tools, chat widgets, A/B testing platforms, ad scripts; all of it runs in the same browser context as the application's own JavaScript, with the same access to the DOM.
That equivalence is the entire problem. A compromised third-party script doesn't need to find a bug in your app. It just needs to execute, and it has the same reach your own code does.
The Polyfill.io incident in June 2024 is the clearest recent example. A Chinese company acquired a widely trusted JavaScript library and weaponized it, injecting malicious code into hundreds of thousands of websites, including platforms run by Hulu, Mercedes-Benz, and WarnerBros. The payload didn't exploit a vulnerability in any of those applications. It arrived through a channel every one of them had already decided to trust.
With client-side JavaScript running across the vast majority of websites, this isn't a niche risk; it's close to universal. Subresource Integrity, or SRI, helps here: it's a cryptographic hash attached to a script tag, and the browser refuses to run the script if the hash doesn't match. That stops tampered delivery from a CDN. But SRI only protects scripts loaded from external URLs. It does nothing for scripts injected dynamically at runtime. Vetting third-party code isn't a task you complete once and file away, either. A library that's safe today can change hands tomorrow.
Why token storage decisions determine how bad a successful XSS attack gets
XSS impact in an SPA scales with whatever JavaScript can reach, and in a modern SPA, that's a lot.
localStorage is readable by any script running in the page. No exceptions, no conditions. If a JWT or session token sits there, a successful XSS payload can read it, and the attacker can now impersonate that user against every API endpoint the token authorizes. That's not a foothold; that's the whole session.
HttpOnly cookies close that particular door. JavaScript can't read them at all, so XSS can't extract them directly, and pairing that with Secure and SameSite=Strict narrows the attacker's options further. This is not a minor configuration choice buried in a setup guide. It's the difference between an XSS bug that lets someone poke at the DOM and one that hands over the user's account.
And tokens aren't the only thing at risk. Payment form fields, personal data rendered in the UI, API responses cached in client-side state; all of it sits in the same JavaScript context an attacker just gained access to. For fintech and anti-fraud products specifically, that opens the door to something quieter than account takeover: a script that silently alters transaction values or skims form inputs before the user even hits submit.
Defense-in-depth for SPA and API-driven frontends
No single control stops all XSS. The goal is a stack where getting past one layer still means running into two or three more.
Sanitize at the rendering site, not just at input. Backend sanitization is necessary, but it stops being sufficient the moment data crosses an API boundary to multiple frontends that each render it differently. Libraries like DOMPurify sanitize HTML immediately before it's inserted into the DOM, and that's the right posture even when the API data is supposedly already clean. Skip dangerouslySetInnerHTML and v-html unless there's genuinely no other way to do the job; when you do use them, wrap the call in DOMPurify.
Content Security Policy. CSP tells the browser exactly which origins are allowed to execute scripts, so injected inline scripts and unexpected external scripts get blocked before they run. A strict CSP with a script-src allowlist is the configuration that actually works; unsafe-inline undoes most of the benefit. CSP doesn't stop the injection itself. It limits what the injected payload can do once it's there, and nonce-based CSP handles the case of SPAs that generate scripts dynamically.
Subresource Integrity for third-party scripts. Hash-check every external script at load time, and pair it with a CSP strict enough to block scripts from origins you haven't approved.
Dependency scanning and library hygiene. Tools like npm audit and Dependabot catch known CVEs in your dependency tree before they reach production. CVE-2024-6783 is the concrete case for why this matters: a framework-level flaw that requires active patch monitoring to catch and close. Worth adding to that routine: periodically auditing which third-party scripts are actually loaded in production, since they tend to pile up quietly over time.
Web Application Firewall as a backstop. A WAF catches known XSS payloads at the network layer, which helps when the code-level defenses above have a gap or get bypassed. It's not a replacement for those layers, though. A WAF sitting in front of an app with no sanitization is a thin line of defense, not a real one.
Why static analysis and automated scanners miss the XSS that matters most in SPAs
Automated scanners are good at what they're built for: known patterns. They'll flag dangerouslySetInnerHTML sitting in source code, catch a cookie missing the HttpOnly flag, and check a CSP header for the obvious weaknesses.
What they don't do is trace a data flow from a specific API endpoint, through a React component, into a DOM sink, across four or five files that don't sit near each other in the codebase. They don't notice that a sanitization library is configured to allow one specific tag that happens to enable script execution. They can't chain a DOM-based XSS finding together with a token storage flaw to show that the two combined mean full account takeover, which is the version of the finding that actually tells you your business risk. And they won't catch that a field labeled "internal use only" is reachable through an endpoint that never checks for authentication in the first place.
Quality penetration testing puts 60 to 80% of engagement time into manual work. Automated scanning is one input into that process, not the process itself. Testing an SPA properly means working with authenticated sessions, understanding client-side routing, and instrumenting the running application rather than just crawling its HTML and calling it coverage. The distance between what a scanner turns up and what a skilled tester turns up is exactly where the real risk sits, unclaimed.
What a rigorous pen test of an SPA actually examines
A real SPA pen test has a specific scope, and it's wider than most people expect.
It covers every API endpoint the frontend touches, authenticated or not. It maps every path data takes from an API response to a DOM render, identifying which sinks are in play and whether sanitization is actually applied at each one. It reviews every script the application loads, including third-party code, checking SRI coverage and whether CSP is actually enforced rather than just declared. It checks cookie and token storage configuration across every authentication flow the app supports. And it looks at client-side routing logic for open redirects or parameter injection.
Every finding needs a working exploit behind it. A report that says "dangerouslySetInnerHTML present" without proving it's exploitable is describing a line of source code, not reporting a vulnerability; those are different things, and the difference matters to whoever has to decide what to fix first. Whitebox access, meaning source code and API documentation in hand, lets a tester map every data flow that exists, not just the ones that happen to surface during a crawl of the live app. And after remediation, retesting confirms the underlying flaw is closed, not just that the one payload used in the original test no longer works.
Here's a red flag worth naming directly: a report that lists "XSS via dangerouslySetInnerHTML" on a component that's already wrapped in DOMPurify. That's a scanner finding a string in the source. It's not a tester who actually read the code. Source-code access paired with a review process built around certified testing is suited to exactly this problem: mapping attack surface outward from the codebase, instead of inward from whatever the UI happens to expose.
How XSS findings fit into SOC 2, HIPAA, and ISO 27001 compliance reporting
None of the major frameworks name cross-site scripting as its own control. XSS still lands squarely inside the application security and data protection controls that auditors examine closely.
For SOC 2 Type II, auditors want evidence of a systematic security testing program, and a pen test report documenting XSS findings, the remediation taken, and the retest results is exactly that kind of evidence. HIPAA's Security Rule doesn't mandate a pen test outright, but its risk analysis requirements mean an undetected client-side vulnerability that exposes ePHI is a compliance gap regardless of whether anyone asked for a test by name. ISO 27001's Annex A includes technical vulnerability management controls that explicitly require identifying and addressing vulnerabilities in systems, and web application XSS sits well within that scope.
The failure mode worth watching for: an organization runs the pen test because compliance requires one, gets a report that never actually probes SPA-specific attack surfaces, walks away with a clean bill of health, and still has real exposure that neither the company nor the auditor ever saw. An audit-ready report and a genuinely safe application are not automatically the same thing. A rigorous test produces both at once: findings that satisfy what an auditor needs to see, and findings that reflect what's actually at risk. Trace's letter of attestation, accepted across SOC 2, HIPAA, and ISO 27001 audits, comes out of testing built to find real vulnerabilities first. The compliance paperwork follows from the security work. It was never the other way around.


