Cross-site scripting (XSS) happens when text from users or other systems is inserted into a page as HTML, letting an attacker run their own JavaScript — stealing data, acting as the user, or defacing the page. It's the most common front-end vulnerability.
Prevention: insert text with textContent (or setAttribute), never innerHTML, outerHTML, insertAdjacentHTML or document.write with untrusted data. Never pass strings to eval, new Function or setTimeout("code"). If you truly must render user HTML (a rich-text editor), sanitise it with a vetted library such as DOMPurify. Validate URLs before using them in href or src — javascript: URLs run code.
A Content Security Policy (CSP) header is a second line of defence: it tells the browser which scripts may run, blocking inline and third-party scripts an attacker injects. This website itself sends a CSP header.
const comment = '<img src=x onerror="alert(document.cookie)"> Great lesson!';
const website = "javascript:alert('hacked')";
const el = document.querySelector("#comments");
// UNSAFE: the browser parses the string as HTML and runs the onerror code
// el.innerHTML = `<p>${comment}</p>`;
// SAFE: build elements and set text
const p = document.createElement("p");
p.textContent = comment; // shown literally, never executed
el.append(p);
// SAFE URLs: allow only http(s)
function safeUrl(input) {
try {
const url = new URL(input, location.origin);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
const link = document.createElement("a");
const href = safeUrl(website);
link.textContent = href ? "Visit website" : "(invalid link removed)";
if (href) link.href = href;
el.append(link);
// When escaping is needed for a template string
function escapeHTML(text) {
return String(text).replace(/[&<>"']/g, (ch) => (
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]
));
}
console.log(escapeHTML(comment));<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Only scripts from this site may run; inline <script> and injected handlers are blocked -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'" />
<title>Comments</title>
<script src="safe-dom.js" defer></script>
</head>
<body>
<div id="comments"></div>
</body>
</html>Key points
- Treat all external text as untrusted: use
textContent, neverinnerHTML. - Allow only
http(s):URLs in links; neverevalstrings. - Sanitise rich HTML with DOMPurify and add a Content Security Policy as backup.
Exercise
Revisit your to-do app and search for every use of innerHTML. Add a CSP meta tag to it, then try adding a task containing <img src=x onerror=alert(1)> and confirm it is displayed as text and nothing runs.
Show solution
Try the exercise yourself first — then compare your approach with this one.
The to-do app already uses textContent everywhere, so there's no innerHTML to fix. Add the CSP meta tag to its <head>: inline scripts and injected event handlers are now blocked even if a mistake slips in later. Then add a task containing an <img onerror> payload and confirm it shows as text and nothing runs.
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" />
<title>Tasks</title>
<script src="app.js" defer></script>
</head>// Find risky DOM API calls in the page's scripts (should print nothing for the to-do app)
const RISKY = [/\.innerHTML\s*=/, /\.outerHTML\s*=/, /insertAdjacentHTML\(/, /document\.write\(/, /\beval\(/];
[...document.scripts].forEach(async (s) => {
if (!s.src) return;
const code = await (await fetch(s.src)).text();
for (const pattern of RISKY) {
if (pattern.test(code)) console.warn(s.src, "matches", pattern);
}
});