On this page · sections
XSS in React
Cross-Site Scripting (XSS) injects malicious scripts into your origin’s context. React escapes JSX text by default — but dangerouslySetInnerHTML, unsanitized markdown, and manual DOM APIs bypass that protection.
| Type | Persistence | Trigger | React-safe default? |
|---|---|---|---|
| Stored | DB / CMS | Any viewer loads page | Text yes; __html no |
| Reflected | URL / request | Victim opens crafted link | Text yes; __html no |
| DOM-based | Client-only | Source → sink in JS | Depends on sink |
Stored XSS
Definition
Attacker saves malicious HTML in persistent storage (comment, bio, CMS article). Every viewer loads the payload in your origin’s context — script steals cookies/tokens, performs actions, defaces UI.
'use client';
import DOMPurify from 'dompurify';
type Comment = { id: string; author: string; bodyHtml: string };
// Vulnerable — bodyHtml from DB, never sanitized on save
function VulnerableComment({ comment }: { comment: Comment }) {
return (
<div className="comment">
<strong>{comment.author}</strong>
<div dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} />
</div>
);
}
// Mitigated — sanitize + escaped author name
function SafeComment({ comment }: { comment: Comment }) {
const clean = DOMPurify.sanitize(comment.bodyHtml, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em'],
ALLOWED_ATTR: [],
});
return (
<div className="comment">
<strong>{comment.author}</strong>
<div dangerouslySetInnerHTML={{ __html: clean }} />
</div>
);
}Mitigation layers:
- Sanitize on ingress — server-side validation before DB write is authoritative
- Sanitize on render — DOMPurify with strict allowlists as second layer
- CSP — contain execution when sanitization fails
- HttpOnly cookies — limit cookie theft even if script runs
Real-world example
SVG upload stored XSS — SVG is XML with script. Serve from separate domain or sanitize aggressively; never inline untrusted SVG.
Interview: Stored XSS lives in data, not framework magic — one upload can affect many victims.
Reflected XSS
Definition
Payload in request (query param, path, header) is immediately reflected in response HTML. Attacker sends victim a crafted link — one click runs script.
'use client';
import { useSearchParams } from 'next/navigation';
// Vulnerable — query reflected via innerHTML
function VulnerableSearch() {
const params = useSearchParams();
const q = params.get('q') ?? '';
return (
<div dangerouslySetInnerHTML={{ __html: `Results for: ${q}` }} />
);
}
// Safe — JSX text escape
function SafeSearch() {
const params = useSearchParams();
const q = params.get('q') ?? '';
return <p>Results for: {q}</p>;
}
// Attack URL: /search?q=<img src=x onerror=alert(document.domain)>Encoding context matters — HTML, attribute, JavaScript, and URL contexts each need different encoding. encodeURIComponent is URL encoding, not HTML encoding.
Frontend introduction points: OAuth error pages reflecting error_description, client routers reading searchParams into innerHTML.
Interview: Reflected XSS works in client-only SPAs — no full page reload required.
DOM-Based XSS
Definition
Server response is static and safe. Client JS reads attacker-controlled source and writes to dangerous sink — no server log of the exploit.
'use client';
import { useEffect, useRef } from 'react';
// DOM XSS — hash written to innerHTML
function VulnerableHashBanner() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
ref.current.innerHTML = window.location.hash.slice(1);
}, []);
return <div ref={ref} />;
}
// Safe — render hash as text
function SafeHashBanner() {
const message = decodeURIComponent(window.location.hash.slice(1));
return <div>{message}</div>;
}
// javascript: in link — validate scheme explicitly
function UserLink({ url, label }: { url: string; label: string }) {
const safe =
url.startsWith('https://') || url.startsWith('mailto:') ? url : '#';
return <a href={safe} rel="noopener noreferrer">{label}</a>;
}Common sources: location.hash, postMessage, poisoned localStorage.
Common sinks: innerHTML, eval, document.write, dynamic href with javascript:.
Always verify event.origin before using postMessage data.
Interview: DOM XSS is 100% frontend sink responsibility — SSR does not prevent client-side sinks.
Content Security Policy
Definition
CSP is an HTTP header declaring which scripts, styles, and resources may load. Injected script without matching nonce is blocked even if HTML parsing succeeded.
// middleware.ts — conceptual Next.js pattern
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
"upgrade-insecure-requests",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce);
return response;
}Primary XSS directives:
script-src— main brake on script execution- Nonce per request — server generates random value, attaches to
<script nonce>and header object-src 'none'— blocks Flash/plugin vectorsbase-uri 'self'— prevents base tag hijack
Rollout: deploy Content-Security-Policy-Report-Only → collect violations → enforce.
Interview: CSP in meta tag is not enough for
frame-ancestors— use HTTP header.
XSS Defense Checklist
Definition
Defense in depth across four phases:
Prevent:
- Escape by default — JSX text, not raw HTML
- No
dangerouslySetInnerHTMLwithout DOMPurify allowlist - Validate URL schemes — block
javascript:anddata:text/html - Sanitize server-side on save — client-only is bypassable
- No
eval,new Function, or string-based timers
Contain:
- Strict CSP with per-request nonces
- HttpOnly session cookies
- Short-lived access tokens in memory — not localStorage
Detect:
- CSP violation reporting endpoint
- WAF on save and search endpoints
- OWASP ZAP or Burp against staging in CI
Respond:
- Purge CDN cache after fix
- Rotate secrets and force logout
'use client';
import DOMPurify from 'dompurify';
const ALLOWED = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href', 'rel'],
};
// Central safe HTML component — only path to rich render
export function SafeHtml({ html, className }: { html: string; className?: string }) {
const clean = DOMPurify.sanitize(html, ALLOWED);
return (
<div className={className} dangerouslySetInnerHTML={{ __html: clean }} />
);
}Real-world example
XSS + localStorage token = account takeover. Align XSS defenses with JWT storage decisions. AI-generated content — treat model output as untrusted user input.
Interview: Frontend owns every DOM sink, third-party script, and URL reflection point.
Senior Interview Summary
Mental model
Stored → Payload in DB; many victims; sanitize on write + read
Reflected → Payload in URL; echoed unsafely; JSX text is safe
DOM-based → Client source → sink; no server log
React → Escapes text nodes; __html and manual DOM APIs bypass
CSP → script-src nonce per request; Report-Only firstPractical rules
- Inventory sinks — grep for
dangerouslySetInnerHTML,innerHTML, dynamichref - Single SafeHtml choke point — easier audit and pen test
- Server-side sanitization authoritative; client is second layer
- Third-party scripts and tag managers are XSS if vendor compromised
- CMS content is user input — broader allowlist OK, same pipeline required
What to say in interviews
I enforce server-side allowlist sanitization on save, client DOMPurify as second layer, strict CSP with nonces, HttpOnly sessions, and an immutable audit log of HTML saves. We do not skip sanitization based on role — compromised admin equals stored XSS for every user.
Common traps
- “React prevents XSS” — only text nodes;
__htmlremains vulnerable - Sanitize only on display — attacker POSTs raw HTML to API
encodeURIComponentis not HTML encoding- DOM XSS is not a backend problem