Single-page apps make XSS easier to hide.
You load one shell, fetch data later, stitch UI together in the browser, and somewhere along the way somebody says, “It’s fine, the framework escapes it.” Sometimes that’s true. Sometimes it’s very, very false.
I’ve seen teams assume that moving from server-rendered templates to React or Vue magically solves XSS. It doesn’t. SPAs change where the bug happens, not whether it can happen.
Here are the most common mistakes I keep seeing in SPA codebases, and how I’d fix them.
Mistake #1: Trusting framework escaping too much
Modern frameworks escape text content by default. That helps a lot:
function Profile({ user }) {
return <h1>{user.displayName}</h1>;
}
If displayName contains <script>alert(1)</script>, React renders it as text.
The mistake happens when developers take that guarantee and apply it everywhere. Frameworks usually protect text nodes and many attribute bindings, but they do not make all DOM injection safe.
The dangerous APIs are still dangerous:
- React:
dangerouslySetInnerHTML - Vue:
v-html - Angular:
bypassSecurityTrustHtml,bypassSecurityTrustUrl, and friends - Vanilla DOM:
innerHTML,outerHTML,insertAdjacentHTML
Bad
function Comment({ body }) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
Better
Render plain text whenever possible:
function Comment({ body }) {
return <p>{body}</p>;
}
If you genuinely need limited HTML, sanitize it before rendering:
import DOMPurify from 'dompurify';
function SafeComment({ body }) {
const clean = DOMPurify.sanitize(body, {
USE_PROFILES: { html: true }
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
My rule is simple: if you didn’t author the HTML yourself, treat it as hostile.
Mistake #2: Sanitizing in the wrong place
A common anti-pattern is sanitizing at input time and assuming the data is now safe forever.
That breaks fast.
The same user content may later be used in different contexts:
- inserted as HTML
- placed inside an attribute
- embedded in a URL
- shown in a JavaScript-generated template
- exported into email or PDF rendering
One sanitization step rarely fits every output context.
Bad
// Saved once, trusted everywhere forever
const sanitizedBio = sanitizeHtml(req.body.bio);
saveUser({ bio: sanitizedBio });
Then six months later:
profileCard.innerHTML = user.bio;
This looks harmless because “we sanitized it already,” but now you’re betting your entire app on old assumptions, old sanitizer config, and every future use of that field.
Better
Store raw content if your product needs it, validate it, and sanitize or encode as close to rendering as possible.
const cleanBio = DOMPurify.sanitize(user.bio, {
ALLOWED_TAGS: ['b', 'i', 'p', 'br', 'strong', 'em']
});
bioContainer.innerHTML = cleanBio;
Context matters. HTML sanitization is not the same as URL validation, and neither is the same as JS string escaping.
Mistake #3: Building HTML with template strings
SPA developers love template literals because they’re quick and readable. They’re also a great way to smuggle attacker-controlled content into the DOM.
Bad
results.innerHTML = items.map(item => `
<li>
<a href="${item.url}">${item.title}</a>
</li>
`).join('');
There are two problems here:
item.titlecan inject HTMLitem.urlcan becomejavascript:alert(1)
Better
Create DOM nodes and assign safe properties explicitly:
for (const item of items) {
const li = document.createElement('li');
const a = document.createElement('a');
a.textContent = item.title;
const url = new URL(item.url, window.location.origin);
if (url.protocol === 'http:' || url.protocol === 'https:') {
a.href = url.toString();
} else {
a.href = '/';
}
li.appendChild(a);
results.appendChild(li);
}
This is a bit more verbose, but it’s predictable. I’ll take predictable over clever every time in security-sensitive UI code.
Mistake #4: Treating URLs as harmless strings
A lot of SPA XSS bugs come from URL-based sinks:
hrefsrc- router redirects
window.location- iframe sources
Developers often validate that a string “looks like a URL” and forget that some schemes execute code.
Bad
<template>
<a :href="user.website">Visit site</a>
</template>
If user.website is javascript:alert(1), you’ve got a problem.
Better
Allow only expected schemes:
function safeUrl(input) {
try {
const url = new URL(input, window.location.origin);
const allowed = ['http:', 'https:'];
return allowed.includes(url.protocol) ? url.toString() : '/';
} catch {
return '/';
}
}
<template>
<a :href="safeUrl(user.website)">Visit site</a>
</template>
Same idea applies to image sources, embeds, and client-side navigation targets.
Mistake #5: Using sanitizer bypass APIs because “the app needs it”
Angular deserves special mention here because it has solid built-in protections, and teams still disable them.
I’ve seen code like this shipped to production:
constructor(private sanitizer: DomSanitizer) {}
getTrustedHtml(content: string) {
return this.sanitizer.bypassSecurityTrustHtml(content);
}
That method name is not subtle. If you call bypassSecurityTrustHtml on untrusted content, you are opting out of Angular’s protection.
Better
Keep Angular’s default sanitization in place:
@Component({
selector: 'app-preview',
template: `<div [innerHTML]="content"></div>`
})
export class PreviewComponent {
content = '<p>Hello</p>';
}
If you need richer HTML, sanitize before binding and be extremely strict about allowed tags and attributes. Use bypass APIs only for content you fully control.
Mistake #6: Forgetting DOM-based XSS in client-side routing and state
SPAs do a lot with query params, hash fragments, and local storage. That data often feels “internal,” which is why people stop treating it as attacker-controlled.
That’s a mistake.
Bad
const params = new URLSearchParams(location.search);
document.querySelector('#message').innerHTML = params.get('msg');
Or:
const savedTheme = localStorage.getItem('themeName');
themeLabel.innerHTML = savedTheme;
If an attacker can get payloads into the URL, local storage, postMessage, or API responses, they can often reach these sinks.
Better
Use text sinks by default:
const params = new URLSearchParams(location.search);
document.querySelector('#message').textContent = params.get('msg') || '';
And validate state before use:
const theme = localStorage.getItem('themeName');
const allowedThemes = new Set(['light', 'dark']);
themeLabel.textContent = allowedThemes.has(theme) ? theme : 'light';
Client-side state is still untrusted input.
Mistake #7: Assuming third-party components are safe
Rich text editors, markdown renderers, chart tooltips, syntax highlighters, analytics widgets, chat plugins — these are regular sources of XSS in SPAs.
The problem usually looks like this:
- package accepts HTML or markdown
- package renders into the DOM
- team assumes package sanitizes correctly
- package either does not sanitize, or sanitizes in a way that doesn’t match your threat model
Better
Treat third-party renderers like any other dangerous sink.
If you render markdown, configure the renderer to disable raw HTML or sanitize after conversion.
const dirtyHtml = markdownToHtml(userMarkdown);
const cleanHtml = DOMPurify.sanitize(dirtyHtml);
preview.innerHTML = cleanHtml;
Also audit component props with names like:
htmlcontentrendertemplateformatter
Those names usually deserve a second look.
Mistake #8: No CSP, or a weak CSP that allows inline scripts
CSP won’t fix an XSS bug, but it can make exploitation much harder. In SPAs, that extra layer is worth having because the client does so much dynamic work.
A weak policy like this barely helps:
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval';
That policy keeps the header alive while turning off the useful parts.
Better
Aim for a strict CSP that avoids inline script and unsafe eval. A starting point might look like:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
For implementation details and rollout strategy, see https://csp-guide.com. For framework-specific behavior, check official documentation for your stack.
If your SPA build tooling still requires unsafe-eval in development, fine. Don’t casually carry that into production.
Mistake #9: No Trusted Types for DOM-heavy apps
If your app has a lot of DOM manipulation, Trusted Types are one of the best guardrails you can add in modern browsers. They help block unsafe HTML/script URL assignments unless the value comes from an approved policy.
This is especially useful when your codebase is large and not every developer has XSS sinks memorized.
A CSP example:
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types default dompurify;
Then define a policy:
const policy = trustedTypes.createPolicy('dompurify', {
createHTML: input => DOMPurify.sanitize(input)
});
element.innerHTML = policy.createHTML(userContent);
Trusted Types won’t replace sanitization. They force discipline around it.
Mistake #10: No security tests for rendering paths
Most teams test API auth harder than they test DOM rendering. That’s backwards for SPAs with lots of user-generated content.
I like to keep a small payload set and run it through every place that renders untrusted data:
const payloads = [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'"><svg/onload=alert(1)>',
'javascript:alert(1)'
];
Then test:
- comments
- profile fields
- search results
- toast messages
- error views
- markdown preview
- query-param driven UI
- admin panels
Even lightweight tests catch a lot:
it('renders comment as text', () => {
renderComment({ body: '<img src=x onerror=alert(1)>' });
expect(document.body.innerHTML).not.toContain('onerror=');
});
You don’t need a giant security program to start. You need coverage on the dangerous rendering paths.
The fix I’d push across most SPA teams
If I were cleaning up XSS risk in a real SPA, I’d push these rules first:
- Default to
textContentor framework text binding - Ban raw
innerHTMLunless there is a documented exception - Sanitize untrusted HTML right before rendering
- Validate URLs by scheme, not by regex alone
- Treat query params, local storage, and postMessage data as untrusted
- Turn on CSP and tighten it over time
- Add Trusted Types if the app is DOM-heavy
- Review every third-party component that renders markup
Frameworks help. They do not absolve you from understanding sinks, contexts, and trust boundaries.
That’s the part people get wrong. XSS in SPAs usually doesn’t come from one catastrophic mistake. It comes from ten small “this should be fine” decisions spread across components, utilities, and plugins.
That’s why the fix is mostly discipline. Use safe sinks. Sanitize the right thing in the right place. Stop turning strings into DOM unless you absolutely have to.