XSS in Modern Single Page Applications
If you’ve worked with modern JavaScript frameworks, you might think XSS is a solved problem. React escapes everything by default. Angular has its DomSanitizer. Vue auto-escapes interpolations. The frameworks do the right thing — until developers go out of their way to do the wrong thing.
And they do. All the time.
I’ve seen dangerouslySetInnerHTML used without sanitization in production React apps. I’ve seen javascript: URLs passed straight into href attributes. I’ve seen new Function() called with user input in template rendering logic. The framework gives you guard rails, and developers climb over them because “it’s the fastest way to get it working.”
This post isn’t a general XSS tutorial — if you need that foundation, check out the OWASP XSS Prevention Cheat Sheet. This is specifically about how XSS shows up in modern Single Page Applications — React, Next.js, and similar frameworks — where the attack surface is different from traditional server-rendered apps.
React’s Built-in XSS Protection
Before we break things, let’s understand what React gets right. By default, React escapes all values embedded in JSX:
const userInput = \"<script>alert('XSS')</script>\";
return <div>{userInput}</div>;
React escapes this automatically and renders the literal text:
<script>alert('XSS')</script>
The browser displays the script tag as text instead of executing it. This is React’s default behavior — JSX expressions are always escaped. You’d have to actively bypass this protection to introduce XSS.
And that’s exactly what the following patterns do.
1. DOM-based XSS via dangerouslySetInnerHTML
The name literally warns you. dangerouslySetInnerHTML is React’s escape hatch for injecting raw HTML into the DOM. It exists for legitimate use cases — rendering markdown content, displaying rich text from a CMS, embedding HTML emails. But when used with unsanitized user input, it’s a direct XSS vector.
Vulnerable Component:
import React, { useState, useEffect } from 'react';
function VulnerableHTMLRenderer() {
const [content, setContent] = useState('');
useEffect(() => {
// Simulating content from URL parameters or API
const urlParams = new URLSearchParams(window.location.search);
const htmlContent = urlParams.get('content') || '<p>Default content</p>';
setContent(htmlContent);
}, []);
// VULNERABLE: Directly rendering HTML without sanitization
return (
<div>
<h2>Dynamic Content Renderer</h2>
<div dangerouslySetInnerHTML= />
</div>
);
}
export default VulnerableHTMLRenderer;
Attack Vector:
http://localhost:3000?content=<img src=x onerror=alert('XSS in React!')>
The content parameter goes straight from the URL into dangerouslySetInnerHTML with zero sanitization. An attacker crafts a URL and sends it to a victim — when they click it, the injected HTML executes in their browser.
Secure Version:
The fix is simple — sanitize the HTML with DOMPurify before rendering:
import React, { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
function SecureHTMLRenderer() {
const [content, setContent] = useState('');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const htmlContent = urlParams.get('content') || '<p>Default content</p>';
// Sanitize HTML before setting state
const cleanContent = DOMPurify.sanitize(htmlContent);
setContent(cleanContent);
}, []);
return (
<div>
<h2>Secure Content Renderer</h2>
<div dangerouslySetInnerHTML= />
</div>
);
}
export default SecureHTMLRenderer;
DOMPurify strips all dangerous elements (script tags, event handlers, etc.) while preserving safe HTML. It’s the gold standard for HTML sanitization in JavaScript.
2. XSS via Unsafe URL Handling
This one catches a lot of developers off guard. React escapes text content, but it does not validate URLs in href attributes. A javascript: URL is a perfectly valid href value — and when the user clicks it, the JavaScript executes.
import React, { useState } from 'react';
function VulnerableProfileCard({ userProfile }) {
const [showDetails, setShowDetails] = useState(false);
// VULNERABLE: Direct href assignment without validation
const profileUrl = userProfile.website || '#';
return (
<div className=\"profile-card\">
<img src={userProfile.avatar} alt=\"Profile\" />
<h3>{userProfile.name}</h3>
<p>{userProfile.bio}</p>
{/* VULNERABLE: Can execute JavaScript URLs */}
<a href={profileUrl} target=\"_blank\" rel=\"noopener\">
Visit Website
</a>
<button onClick={() => setShowDetails(!showDetails)}>
Toggle Details
</button>
{showDetails && (
<div>
{/* VULNERABLE: If description contains HTML */}
<div dangerouslySetInnerHTML= />
</div>
)}
</div>
);
}
// Usage with malicious data:
const maliciousProfile = {
name: \"John Doe\",
website: \"javascript:alert('XSS via href!')\",
description: \"<img src=x onerror=alert('Stored XSS')>\",
avatar: \"profile.jpg\",
bio: \"Software Developer\"
};
export default VulnerableProfileCard;
There are three XSS vectors in this single component:
- JavaScript URL in href —
javascript:alert('XSS')executes when the link is clicked - Data URL —
data:text/html,<script>alert('XSS')</script>can also execute code - Unsanitized HTML in description —
dangerouslySetInnerHTMLwithout DOMPurify
The secure version validates URLs and sanitizes HTML:
import React, { useState } from 'react';
import DOMPurify from 'dompurify';
function SecureProfileCard({ userProfile }) {
const [showDetails, setShowDetails] = useState(false);
// Secure URL validation
const validateUrl = (url) => {
if (!url || url === '#') return '#';
try {
const parsedUrl = new URL(url);
// Only allow http and https protocols
if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
return url;
}
return '#';
} catch {
return '#';
}
};
const safeUrl = validateUrl(userProfile.website);
const safeDescription = DOMPurify.sanitize(userProfile.description || '');
return (
<div className=\"profile-card\">
<img src={userProfile.avatar} alt=\"Profile\" />
<h3>{userProfile.name}</h3>
<p>{userProfile.bio}</p>
<a href={safeUrl} target=\"_blank\" rel=\"noopener noreferrer\">
{safeUrl === '#' ? 'No website available' : 'Visit Website'}
</a>
<button onClick={() => setShowDetails(!showDetails)}>
Toggle Details
</button>
{showDetails && (
<div>
<div dangerouslySetInnerHTML= />
</div>
)}
</div>
);
}
export default SecureProfileCard;
The key: URL validation must be a whitelist (only allow http: and https:), not a blacklist (trying to block javascript:). Blacklists are always incomplete — there are too many encoding tricks and edge cases.
3. Client-Side Template Injection
This is less common but more dangerous. When developers build their own template rendering using eval(), new Function(), or setTimeout() with string arguments, they create code injection vulnerabilities that bypass React’s protections entirely.
import React, { useState, useEffect } from 'react';
function VulnerableDynamicRenderer() {
const [template, setTemplate] = useState('');
const [userData, setUserData] = useState({});
useEffect(() => {
// Simulating template from API or user input
const urlParams = new URLSearchParams(window.location.search);
const templateStr = urlParams.get('template') || 'Hello !';
const name = urlParams.get('name') || 'Guest';
setTemplate(templateStr);
setUserData({ name });
}, []);
// VULNERABLE: Using eval or Function constructor for templating
const renderTemplate = (template, data) => {
try {
// DANGEROUS: Never do this!
const code = template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return `' + data.${key} + '`;
});
const finalCode = `return '${code}';`;
const func = new Function('data', finalCode);
return func(data);
} catch (e) {
return 'Template error';
}
};
const renderedContent = renderTemplate(template, userData);
return (
<div>
<h2>Dynamic Template Renderer</h2>
<div dangerouslySetInnerHTML= />
</div>
);
}
export default VulnerableDynamicRenderer;
http://localhost:3000?template='+alert('XSS')+'&name=User
import React, { useState, useEffect } from 'react';
function SecureDynamicRenderer() {
const [template, setTemplate] = useState('');
const [userData, setUserData] = useState({});
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const templateStr = urlParams.get('template') || 'Hello !';
const name = urlParams.get('name') || 'Guest';
// Validate template - only allow specific patterns
const safeTemplate = validateTemplate(templateStr);
setTemplate(safeTemplate);
setUserData({ name: sanitizeString(name) });
}, []);
const validateTemplate = (template) => {
// Only allow simple variable substitution
const allowedPattern = /^[a-zA-Z0-9\s\{\}]+$/;
if (!allowedPattern.test(template)) {
return 'Hello !'; // Fallback to safe template
}
return template;
};
const sanitizeString = (str) => {
return str.replace(/[<>'\"&]/g, (char) => {
const entities = {
'<': '<',
'>': '>',
'\"': '"',
\"'\": ''',
'&': '&'
};
return entities[char];
});
};
// Safe template rendering without eval
const renderTemplate = (template, data) => {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return data[key] || '';
});
};
const renderedContent = renderTemplate(template, userData);
return (
<div>
<h2>Secure Template Renderer</h2>
<div>{renderedContent}</div> {/* Using regular JSX instead of dangerouslySetInnerHTML */}
</div>
);
}
export default SecureDynamicRenderer;
The fix: never use eval(), new Function(), or setTimeout(string) with user input. Use simple string replacement with ``-style placeholders — and render the result as text through JSX, not as HTML.
4. XSS via Server-Side Rendering (SSR) Data Injection
Next.js and other SSR frameworks introduce a unique XSS surface. When server-side data is injected into the client-side page — through <script> tags, __NEXT_DATA__, or manual script injection — unsanitized data can execute as JavaScript.
// pages/profile/[id].js - Vulnerable Next.js page
import { GetServerSideProps } from 'next';
function ProfilePage({ profile, initialData }) {
useEffect(() => {
// VULNERABLE: Injecting server data directly into client-side script
const script = document.createElement('script');
script.innerHTML = `
window.profileData = ${initialData};
console.log('Profile loaded:', window.profileData);
`;
document.head.appendChild(script);
}, [initialData]);
return (
<div>
<h1>{profile.name}</h1>
<div dangerouslySetInnerHTML= />
</div>
);
}
export const getServerSideProps = async ({ params }) => {
// Simulating data from database that might contain XSS
const profile = {
name: \"John Doe\",
bio: \"<script>alert('SSR XSS')</script>\"
};
// VULNERABLE: Not properly escaping data for client-side injection
const initialData = JSON.stringify(profile);
return {
props: {
profile,
initialData
}
};
};
export default ProfilePage;
// pages/profile/[id].js - Secure Next.js page
import { GetServerSideProps } from 'next';
import DOMPurify from 'isomorphic-dompurify';
import { useEffect, useState } from 'react';
function SecureProfilePage({ profile }) {
const [sanitizedBio, setSanitizedBio] = useState('');
useEffect(() => {
// Sanitize on client-side after hydration
setSanitizedBio(DOMPurify.sanitize(profile.bio));
}, [profile.bio]);
return (
<div>
<h1>{profile.name}</h1>
{sanitizedBio && (
<div dangerouslySetInnerHTML= />
)}
</div>
);
}
export const getServerSideProps = async ({ params }) => {
const profile = {
name: \"John Doe\",
bio: \"<p>This is a safe bio</p>\"
};
// Server-side sanitization as well
profile.bio = DOMPurify.sanitize(profile.bio);
return {
props: {
profile
}
};
};
export default SecureProfilePage;
Comprehensive Prevention Strategies for React SPAs
1. Content Security Policy (CSP) Implementation
// In your React app's public/index.html or via headers
<meta http-equiv=\"Content-Security-Policy\"
content=\"default-src 'self';
script-src 'self' 'unsafe-inline' https://trusted-cdn.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.yourdomain.com;\">
2. Input Validation Hook
import { useState, useCallback } from 'react';
function useSecureInput(initialValue = '') {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState('');
const setSecureValue = useCallback((newValue) => {
// Basic XSS prevention
if (typeof newValue !== 'string') {
setError('Invalid input type');
return;
}
// Check for obvious XSS patterns
const xssPatterns = [
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
/javascript:/gi,
/on\w+\s*=/gi,
/<iframe/gi,
/<object/gi,
/<embed/gi
];
const hasXSS = xssPatterns.some(pattern => pattern.test(newValue));
if (hasXSS) {
setError('Potentially malicious content detected');
return;
}
setError('');
setValue(newValue);
}, []);
return [value, setSecureValue, error];
}
// Usage:
function SecureForm() {
const [comment, setComment, commentError] = useSecureInput('');
return (
<div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{commentError && <div className=\"error\">{commentError}</div>}
</div>
);
}
3. Safe URL Validation Utility
const URLValidator = {
isValidHttpUrl: (string) => {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === \"http:\" || url.protocol === \"https:\";
},
sanitizeUrl: (url) => {
if (!url || url === '#') return '#';
// Remove javascript: and data: URLs
if (url.toLowerCase().startsWith('javascript:') ||
url.toLowerCase().startsWith('data:')) {
return '#';
}
if (URLValidator.isValidHttpUrl(url)) {
return url;
}
return '#';
}
};
// Usage in component:
function SafeLink({ href, children }) {
const safeHref = URLValidator.sanitizeUrl(href);
return (
<a href={safeHref} target=\"_blank\" rel=\"noopener noreferrer\">
{children}
</a>
);
}
4. Secure HTML Rendering Component
import DOMPurify from 'dompurify';
function SecureHTMLContent({ htmlContent, allowedTags = [] }) {
const defaultConfig = {
ALLOWED_TAGS: allowedTags.length > 0 ? allowedTags : ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false
};
const sanitizedHTML = DOMPurify.sanitize(htmlContent, defaultConfig);
return (
<div dangerouslySetInnerHTML= />
);
}
// Usage:
function BlogPost({ content }) {
return (
<SecureHTMLContent
htmlContent={content}
allowedTags={['p', 'h1', 'h2', 'h3', 'strong', 'em', 'a', 'ul', 'ol', 'li']}
/>
);
}
5. Environment-Specific Security Configuration
// config/security.js
const securityConfig = {
development: {
enableCSP: false,
strictValidation: false,
logSecurityIssues: true
},
production: {
enableCSP: true,
strictValidation: true,
logSecurityIssues: false
}
};
export const getSecurityConfig = () => {
return securityConfig[process.env.NODE_ENV] || securityConfig.production;
};
Testing XSS in React Applications
Manual Testing Payloads:
// URL parameters
?search=<img src=x onerror=alert('XSS')>
// Form inputs
<svg onload=alert('XSS')>
javascript:alert('XSS')
\"><script>alert('XSS')</script>
// JSON injection (for APIs)
{\"name\": \"<script>alert('XSS')</script>\"}
Automated Testing with Jest:
import { render, screen } from '@testing-library/react';
import DOMPurify from 'dompurify';
import SecureComponent from './SecureComponent';
describe('XSS Protection Tests', () => {
test('should sanitize malicious script tags', () => {
const maliciousInput = '<script>alert(\"XSS\")</script><p>Safe content</p>';
const sanitized = DOMPurify.sanitize(maliciousInput);
expect(sanitized).not.toContain('<script>');
expect(sanitized).toContain('<p>Safe content</p>');
});
test('should handle javascript: URLs safely', () => {
const maliciousUrl = 'javascript:alert(\"XSS\")';
render(<SecureComponent url={maliciousUrl} />);
const link = screen.getByRole('link');
expect(link.getAttribute('href')).toBe('#');
});
});
Key Takeaways for React XSS Prevention
- Never use
dangerouslySetInnerHTMLwithout sanitization - Validate and sanitize all URLs before using in href attributes
- Avoid using
eval(),Function(), orsetTimeout()with strings - Implement Content Security Policy (CSP)
- Use libraries like DOMPurify for HTML sanitization
- Validate all user inputs on both client and server side
- Be extra careful with SSR data injection
- Test your application with XSS payloads regularly
React’s built-in protections are excellent — they handle the 90% case correctly. But every time a developer reaches for dangerouslySetInnerHTML, eval(), javascript: URLs, or direct DOM manipulation, they’re stepping outside those protections.
The rule is simple: treat all user input as hostile. Sanitize HTML with DOMPurify. Validate URLs against a whitelist. Never execute strings as code. And test your application with XSS payloads regularly — the XSS payload list by PayloadsAllTheThings is a good starting point.
Thanks for reading!