Frontend Security: XSS Prevention and CSP
Cross-Site Scripting (XSS) attacks occur when attackers inject malicious scripts into web applications that execute in other users' browsers. Despite being well-understood for decades, XSS...
Key Insights
- XSS attacks remain one of the most common web vulnerabilities, but modern frameworks provide built-in protections that developers often bypass with dangerous patterns like
dangerouslySetInnerHTMLorv-html. - Content Security Policy acts as a critical second line of defense by restricting what scripts can execute, even if an attacker manages to inject malicious code into your application.
- A defense-in-depth approach combining input sanitization, secure coding practices, and a strict CSP policy reduces XSS risk by over 95% compared to relying on any single technique alone.
Introduction to XSS Vulnerabilities
Cross-Site Scripting (XSS) attacks occur when attackers inject malicious scripts into web applications that execute in other users’ browsers. Despite being well-understood for decades, XSS consistently ranks in the OWASP Top 10 because developers continue to make the same fundamental mistakes.
There are three main types of XSS attacks:
Reflected XSS happens when user input is immediately returned in the response without proper sanitization. A malicious link contains the attack payload, and the server reflects it back to the victim.
Stored XSS is more dangerous—the malicious script is permanently stored on the server (in a database, comment system, or user profile) and executed whenever other users view the infected content.
DOM-based XSS occurs entirely client-side when JavaScript reads user-controlled data and writes it to the DOM without proper validation.
Here’s a simple vulnerable example:
// VULNERABLE: Never do this
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('name');
document.getElementById('greeting').innerHTML = `Hello, ${username}!`;
An attacker could craft a URL like ?name=<img src=x onerror=alert(document.cookie)> and steal session cookies from any user who clicks the link.
XSS Prevention Techniques
The first rule of XSS prevention: never trust user input. This applies to URL parameters, form data, cookies, and even data from your own database (which may have been compromised).
Input Validation and Output Encoding
Input validation should be strict and context-aware. However, validation alone isn’t sufficient—you must also encode output based on where it’s being inserted.
// SECURE: Using textContent instead of innerHTML
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('name');
document.getElementById('greeting').textContent = `Hello, ${username}!`;
The textContent API automatically escapes HTML entities, preventing script execution. This is the single most important change you can make.
Framework-Specific Protections
Modern frameworks like React, Vue, and Angular provide automatic escaping by default:
// React - SECURE by default
function Greeting({ username }) {
return <div>Hello, {username}!</div>;
}
// React - VULNERABLE when you bypass protections
function DangerousGreeting({ htmlContent }) {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}
<!-- Vue - SECURE by default -->
<div>Hello, {{ username }}!</div>
<!-- Vue - VULNERABLE when you bypass protections -->
<div v-html="htmlContent"></div>
Only use dangerouslySetInnerHTML, v-html, or [innerHTML] when absolutely necessary, and always sanitize the content first.
Using DOMPurify for Rich Content
When you need to render user-generated HTML (like in a WYSIWYG editor), use a battle-tested sanitization library:
import DOMPurify from 'dompurify';
// Sanitize user-generated HTML
const dirtyHTML = userInput;
const cleanHTML = DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href']
});
document.getElementById('content').innerHTML = cleanHTML;
DOMPurify removes dangerous elements and attributes while preserving safe formatting. Configure it based on your specific needs—the more restrictive, the better.
Content Security Policy (CSP) Fundamentals
Even with perfect input sanitization, bugs happen. Content Security Policy provides a crucial second layer of defense by controlling what resources the browser is allowed to load and execute.
CSP is implemented via HTTP headers:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.example.com; frame-ancestors 'none';
Let’s break down the key directives:
default-src 'self': Only load resources from the same origin by defaultscript-src 'self' https://trusted-cdn.com: Scripts only from your domain and specified CDNstyle-src 'self' 'unsafe-inline': Styles from your domain plus inline styles (not ideal, but common)img-src 'self' data: https:: Images from your domain, data URIs, and any HTTPS sourceconnect-src 'self' https://api.example.com: AJAX/WebSocket connections restrictedframe-ancestors 'none': Prevent your site from being embedded in iframes
You can also set CSP via meta tags, though HTTP headers are preferred:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'">
Start with a restrictive policy in report-only mode to identify what needs to be allowed:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violation-report
Advanced CSP Strategies
The 'unsafe-inline' directive defeats much of CSP’s purpose because it allows inline scripts—exactly what attackers inject. Instead, use nonces or hashes.
Nonces for Inline Scripts
A nonce is a cryptographically random value generated for each request:
// Server-side (Express example)
const crypto = require('crypto');
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
res.setHeader(
'Content-Security-Policy',
`script-src 'self' 'nonce-${res.locals.nonce}'`
);
next();
});
<!-- In your template -->
<script nonce="<%= nonce %>">
// This inline script will execute
console.log('Allowed by nonce');
</script>
<script>
// This inline script will be blocked (no nonce)
console.log('Blocked!');
</script>
Script Hashes
For static inline scripts, use hashes instead of nonces:
<script>
console.log('Static script');
</script>
Generate the SHA-256 hash of the script content and add it to your CSP:
Content-Security-Policy: script-src 'self' 'sha256-abc123...'
The strict-dynamic Directive
Modern CSP policies use 'strict-dynamic' to trust scripts loaded by already-trusted scripts:
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none';
This allows your nonce-approved scripts to load additional scripts dynamically, while still blocking injected inline scripts.
CSP Violation Reporting
Monitor CSP violations to catch attacks and policy misconfigurations:
Content-Security-Policy: default-src 'self'; report-uri /csp-report; report-to csp-endpoint
// Express endpoint to receive reports
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP Violation:', req.body);
// Log to your monitoring service
res.status(204).end();
});
Common Pitfalls and Best Practices
Avoid These Unsafe Patterns
Don’t use 'unsafe-eval' unless absolutely necessary. It allows eval(), Function(), and setTimeout(string), which are common XSS vectors:
// BAD: Requires 'unsafe-eval'
const code = userInput;
eval(code); // Never do this
// GOOD: Use safe alternatives
const data = JSON.parse(userInput);
Don’t whitelist data: or https: for scripts. This effectively disables CSP:
# TOO PERMISSIVE
script-src https:; # Allows scripts from ANY HTTPS source
Don’t forget about base-uri. Without it, attackers can inject <base> tags to hijack relative URLs:
Content-Security-Policy: base-uri 'self';
Testing Your CSP
Use browser DevTools to debug CSP violations. Chrome and Firefox show detailed violation messages in the Console.
Test with tools like Google’s CSP Evaluator (csp-evaluator.withgoogle.com) to identify weak policies.
// Automated CSP testing
describe('CSP Headers', () => {
it('should include strict CSP policy', async () => {
const response = await fetch('/');
const csp = response.headers.get('content-security-policy');
expect(csp).toContain("default-src 'self'");
expect(csp).not.toContain("'unsafe-inline'");
expect(csp).not.toContain("'unsafe-eval'");
});
});
Migration Strategy for Legacy Apps
Start with CSP in report-only mode, collect violations for a week, then iteratively tighten the policy:
- Deploy
Content-Security-Policy-Report-Onlywith a permissive policy - Analyze violation reports to understand your application’s needs
- Refactor inline scripts to use nonces or external files
- Switch to enforcing mode with
Content-Security-Policy - Gradually remove
'unsafe-inline'and'unsafe-eval'
Conclusion and Checklist
XSS prevention requires a defense-in-depth approach. No single technique is bulletproof, but combining multiple layers makes successful attacks exponentially harder.
Essential Security Checklist:
- Use
textContentor framework defaults instead ofinnerHTML - Sanitize user-generated HTML with DOMPurify before rendering
- Implement a strict CSP with
default-src 'self' - Use nonces or hashes for inline scripts instead of
'unsafe-inline' - Set
base-uri 'self'andobject-src 'none' - Enable CSP violation reporting
- Validate and sanitize all user input server-side
- Use HTTPS everywhere to prevent man-in-the-middle attacks
- Set
HttpOnlyandSecureflags on cookies - Regularly audit dependencies for known vulnerabilities
Recommended Tools:
- DOMPurify for HTML sanitization
- Google CSP Evaluator for policy analysis
- Mozilla Observatory for comprehensive security scanning
- OWASP ZAP for penetration testing
Security is not a one-time implementation—it’s an ongoing process. Review your CSP policies quarterly, stay updated on new attack vectors, and always assume that user input is malicious until proven otherwise. The combination of secure coding practices and a properly configured CSP will protect your users from the vast majority of XSS attacks.