Skip to content
AgileLogics
7 min read

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 &lt; 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! &lt;img src=x onerror="console.log('XSS ran')"&gt;
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-html or 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>
  const dirty = '<p>Hello <b>there</b><img src=x onerror="alert(1)"><script>alert(2)</' + 'script><a href="javascript:alert(3)">link</a></p>';
  document.getElementById("safe").setHTML(dirty);
  console.log(document.getElementById("safe").innerHTML);
</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.

Related developer tools