Sanitizing User Content
Prerequisites: Content security policy
Cross-site scripting (XSS) happens when text from a user ends up in
a page as markup. If a comment containing
<img src=x onerror="stealCookies()"> is inserted as HTML, every
visitor runs the attacker's code.
Rule 1: treat user data as text
On the server, escape data for the context you're writing into.
Template engines do this by default — {{ $comment }} in Laravel Blade,
{{ comment }} in Jinja or Twig — turning < into < and so on.
Their "raw" outputs ({!! !!}, |safe, |raw) switch it off.
In the browser, use APIs that insert text:
<p id="out1"></p>
<p id="out2"></p>
<script>
const comment = 'Nice! <img src=x onerror="console.log(\'XSS ran\')">';
document.getElementById("out1").textContent = comment;
console.log(document.getElementById("out1").innerHTML);
document.getElementById("out2").innerHTML = comment;
console.log(document.getElementById("out2").innerHTML);
</script>
Nice! <img src=x onerror="console.log('XSS ran')">
Nice! <img src="x" onerror="console.log('XSS ran')">
XSS ran
textContent showed the comment as harmless text. innerHTML created a
real img element whose onerror handler ran the attacker's code (the
last line appears once the broken image fails to load).
Escaping depends on the context
| Where the data goes | What to do |
|---|---|
| Element content | Escape <, >, & |
| Quoted attribute value | Also escape " and ' — and always quote attributes |
href / src URL |
Allow only http:/https: (block javascript:), then escape |
Inside <script> |
Encode as JSON and escape < (see How the HTML Parser Works) |
Inside style / CSS |
Avoid — allowlist values instead |
| Event handler attributes | Never put user data here |
Rule 2: when you must allow HTML, sanitize it
Rich-text comments, CMS content, and Markdown output need some HTML. Use a well-tested sanitizer with an allowlist — never write your own regex-based filter; the parser's recovery rules (Level 3's first lesson) make that nearly impossible to get right.
- In the browser: DOMPurify
(
el.innerHTML = DOMPurify.sanitize(dirty)). - On the server: HTML Purifier or
symfony/html-sanitizer(PHP),nh3/bleach(Python),sanitize-htmlor DOMPurify with jsdom (Node.js).
The HTML Sanitizer API
Browsers are adding a built-in sanitizer. setHTML() parses the string
and always removes script-capable content, such as script elements,
event-handler attributes, and javascript: URLs:
<div id="safe"></div>
<script></script>
<p>Hello <b>there</b><a>link</a></p>
setHTML shipped in Chrome and Edge 146 and Firefox 148; Safari doesn't
support it yet. Feature-detect it ("setHTML" in Element.prototype) and
fall back to DOMPurify.
Defense in depth
- Escape or sanitize (required).
- A strict CSP so injected scripts don't run.
- HttpOnly cookies so scripts can't read session cookies.
- Trusted Types to catch dangerous DOM sinks in your own code.