Safely Rendering User-Generated HTML

How to Safely Render User-Generated HTML in JavaScript

User-generated HTML is common in modern web applications. Users may write comments, publish blog posts, create profiles, submit reviews, or format messages using rich-text editors. JavaScript makes it easy to display this content on a webpage, but rendering HTML that comes from users can introduce serious security risks if it is not handled correctly.

One of the biggest concerns is Cross-Site Scripting (XSS). An attacker may submit HTML containing malicious scripts, event handlers, or dangerous URLs. If an application renders that content without proper sanitization, the browser may execute unwanted code in the context of the website.

The good news is that you can safely render user-generated HTML in JavaScript by choosing the right rendering method, using a trusted HTML sanitizer, and following secure coding practices.

In this guide, you will learn how to render user-generated HTML safely, why common approaches fail, how to use DOMPurify, and how to protect your application against common XSS vulnerabilities.

What Is User-Generated HTML?

User-generated HTML is HTML content created or submitted by users rather than directly written by application developers.

For example, a blogging platform may allow users to format articles using headings, links, lists, and bold text. A discussion forum may let users add formatted comments, while a content management system may accept HTML from a rich-text editor.

A simple example of user-generated HTML looks like this:

<h2>My First Blog Post</h2>
<p>This is a paragraph with <strong>bold text</strong>.</p>
<a href="https://example.com">Visit this website</a>

This content contains legitimate HTML elements that improve readability and presentation.

However, users can also submit potentially dangerous HTML:

<img src="x" onerror="alert('XSS')">

If this markup reaches the browser without appropriate protection, its event handler may execute when the image fails to load.

Another example is a link containing a dangerous URL:

<a href="javascript:alert('XSS')">Click here</a>

Simply allowing HTML does not mean every HTML element, attribute, or URL should be trusted.

The goal is to preserve the formatting your application needs while removing or blocking content that could create a security vulnerability.

Why Is Rendering User-Generated HTML Dangerous?

The primary risk is Cross-Site Scripting (XSS), a vulnerability that allows an attacker to inject content that executes JavaScript in another user’s browser.

Depending on the application, a successful XSS attack may expose sensitive information, perform unauthorized actions through a victim’s session, or manipulate the page to deceive users.

There are three common types of XSS to understand.

1. Stored XSS

Stored XSS occurs when an application saves malicious content and later displays it to other users.

For example, an attacker submits a comment containing a malicious event handler. The application stores the comment in a database and renders it on every page where the comment appears.

If the application does not sanitize the HTML, other visitors may trigger the attack simply by viewing the page.

2. Reflected XSS

Reflected XSS occurs when untrusted input is immediately included in a server response or page without proper handling.

For example, an application might read a search query from a URL and insert it into an HTML response. If that input is interpreted as markup, an attacker may exploit the page.

3. DOM-Based XSS

DOM-based XSS happens when client-side JavaScript takes untrusted data and inserts it into the page in an unsafe way.

Consider this example:

const params = new URLSearchParams(window.location.search);
const message = params.get("message");

document.querySelector("#output").innerHTML = message;

An attacker could craft a URL containing HTML with a malicious event handler. If the application inserts that value directly into the DOM, the browser may interpret the input as HTML.

The important lesson is that user input must remain untrusted, even when it comes from your own database, API, or application interface.

Use textContent When HTML Formatting Is Not Required

The safest way to display user input is often to treat it as plain text.

If your application only needs to display a name, comment, message, or search query, use textContent instead of innerHTML.

For example:

const userMessage = "<img src=x onerror=alert('XSS')>";

const output = document.querySelector("#output");

output.textContent = userMessage;

The browser displays the supplied characters as text rather than interpreting them as HTML markup.

The output looks like this:

<img src=x onerror=alert('XSS')>

The event handler is not executed because the browser does not parse the string as HTML.

Why Is textContent Safer?

The textContent property creates or updates text content without parsing the supplied string as HTML.

Compare these two approaches:

element.innerHTML = userInput;
element.textContent = userInput;

The first approach asks the browser to interpret the string as markup. The second treats it as text.

For most user-generated content, textContent is the preferred choice because it avoids unnecessary HTML interpretation.

Use this rule: If your application does not need users to submit formatted HTML, do not render their input as HTML.

However, if your application supports rich-text formatting, textContent alone will not meet your requirements. In that situation, you need a reliable HTML sanitization strategy.

Use DOMPurify to Sanitize User-Generated HTML

When an application genuinely needs to display user-generated HTML, a maintained HTML sanitizer such as DOMPurify is a practical solution.

DOMPurify is a JavaScript library designed to sanitize HTML and remove markup that could introduce XSS vulnerabilities.

It can preserve common formatting elements, such as paragraphs, headings, lists, and links, while removing many dangerous elements and attributes.

Step 1: Install DOMPurify

If you are using npm, install the package with:

npm install dompurify

In a JavaScript project that supports module imports, you can import it like this:

import DOMPurify from "dompurify";

DOMPurify can also be used through a browser-compatible build. For production applications, prefer a properly managed dependency rather than relying on an unpinned external script.

Step 2: Prepare the HTML Input

Suppose your application receives HTML from a rich-text editor:

const userHTML = `
  <h2>My Article</h2>
  <p>This is a <strong>formatted</strong> paragraph.</p>
  <a href="https://example.com">Read more</a>
`;

This example contains formatting that you may want to preserve.

However, you should not assume that content from an editor is automatically safe. A malicious user can modify requests or submit content outside the editor’s normal interface.

Step 3: Sanitize the HTML

Use DOMPurify to clean the content before rendering it:

const cleanHTML = DOMPurify.sanitize(userHTML);

The sanitizer examines the markup and removes content that violates its security rules.

For example, dangerous event-handler attributes and many unsafe elements are removed by the default sanitization process.

Step 4: Render the Sanitized HTML

After sanitization, insert the result into the intended element:

const output = document.querySelector("#output");

output.innerHTML = cleanHTML;

The HTML is now processed through a sanitizer before being interpreted by the browser.

The complete example looks like this:

import DOMPurify from "dompurify";

const userHTML = `
  <h2>My Article</h2>
  <p>This is a <strong>formatted</strong> paragraph.</p>
  <img src="x" onerror="alert('XSS')">
`;

const cleanHTML = DOMPurify.sanitize(userHTML);

document.querySelector("#output").innerHTML = cleanHTML;

This approach allows supported formatting to remain while removing the malicious image event handler.

Remember that sanitization depends on the library’s configuration, version, and supported environment. Keep DOMPurify updated and avoid adding unsafe custom rules that weaken its protections.

Allow Only the HTML Elements Your Application Needs

Not every application needs the same set of HTML elements.

A simple comment system might only allow paragraphs, bold text, italic text, and links. A blogging platform may also need headings, lists, and blockquotes.

You can configure DOMPurify to restrict the allowed elements.

For example:

const cleanHTML = DOMPurify.sanitize(userHTML, {
  ALLOWED_TAGS: [
    "p",
    "br",
    "strong",
    "em",
    "ul",
    "ol",
    "li",
    "a"
  ],
  ALLOWED_ATTR: [
    "href",
    "title"
  ]
});

This configuration creates a more limited HTML policy for content such as comments or short messages.

Elements outside the allowed list are removed according to the sanitizer’s rules.

Why Use an Allowlist?

An allowlist defines exactly which HTML features users can use.

This approach is generally preferable to trying to identify every possible dangerous element and blocking it individually.

An application that accepts arbitrary HTML may unintentionally permit features it never intended to support. Restricting the allowed elements and attributes reduces the number of features that need security review.

However, allowlists must be designed around the application’s requirements. Do not copy a restrictive configuration into a rich-text editor without checking which features it needs.

Also remember that allowing an attribute does not automatically make every value safe. URLs require particular attention.

Handle Links and URLs Carefully

Links are useful in user-generated content, but their destinations can introduce security risks.

For example:

<a href="javascript:alert('XSS')">Open link</a>

A browser may execute a JavaScript URL when the link is activated, depending on the context and browser behavior.

A secure application should allow only the URL schemes it actually needs, such as HTTPS and, where appropriate, HTTP.

DOMPurify includes protections for many unsafe URL patterns by default. Do not disable those protections without a strong, well-reviewed reason.

For additional control, you can restrict allowed URL protocols:

const cleanHTML = DOMPurify.sanitize(userHTML, {
  ALLOWED_TAGS: [
    "p",
    "a",
    "strong",
    "em",
    "br"
  ],
  ALLOWED_ATTR: [
    "href",
    "title"
  ],
  ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
});

This regular expression is an example of a more permissive URI policy that follows DOMPurify’s general approach to protocol filtering. It is not a strict HTTPS-only allowlist and should not be copied as a substitute for a policy designed around your application’s requirements.

If you need to allow only HTTPS URLs, use a carefully tested, stricter policy or validate link destinations separately with the browser’s URL API.

For example:

function isAllowedURL(value) {
  try {
    const url = new URL(value);

    return url.protocol === "https:";
  } catch {
    return false;
  }
}

This function accepts absolute HTTPS URLs and rejects relative URLs, malformed URLs, and other protocols. If your application needs relative links or mailto: links, adapt the policy accordingly.

URL validation and HTML sanitization solve related but different problems. Use the appropriate protection for each context.

Never Use Regular Expressions as Your HTML Sanitizer

A common mistake is attempting to remove dangerous HTML using a regular expression.

For example:

const cleanHTML = userHTML.replace(
  /<script.*?>.*?<\/script>/gi,
  ""
);

This may remove some script elements, but it does not provide reliable HTML sanitization.

HTML has complex parsing rules. Dangerous behavior can appear through event handlers, unsafe URLs, malformed markup, SVG elements, and other constructs that a simple expression may fail to identify.

For example:

<img src="invalid-image" onerror="alert('XSS')">

The regular expression above does not remove the event handler.

A second problem is that HTML can be represented in many different ways. Trying to account for every dangerous variation using regular expressions creates an ongoing security burden.

Instead, use a purpose-built HTML sanitizer such as DOMPurify.

Regular expressions remain useful for ordinary text validation, such as checking whether a value follows a simple identifier format. They should not be used as a replacement for a proper HTML parser and sanitizer.

Sanitize HTML at the Correct Point in Your Application

Sanitization should happen before untrusted HTML is interpreted by the browser.

A typical client-side rendering process looks like this:

  1. Receive the user-generated content.
  2. Apply the appropriate sanitization policy.
  3. Render the sanitized result in the intended HTML container.
  4. Avoid modifying the sanitized markup in ways that introduce unsafe content.

For example:

function renderUserHTML(container, userHTML) {
  const cleanHTML = DOMPurify.sanitize(userHTML);

  container.innerHTML = cleanHTML;
}

You can reuse this function wherever your application intentionally supports user-generated HTML.

However, this helper should not become an excuse to use innerHTML for every kind of input. Use textContent for plain text and the sanitizer only when HTML rendering is genuinely required.

Should You Sanitize Content on the Server?

Server-side sanitization can be useful when your application stores user-generated HTML in a database or serves it to multiple clients.

For example, a content management system might sanitize submitted articles before saving them.

However, server-side sanitization does not eliminate the need to handle browser rendering safely. Different parts of an application may use the same content in different contexts.

A strong strategy includes:

  • Validating and sanitizing content on the server when appropriate.
  • Using a maintained sanitizer compatible with the server environment.
  • Applying the correct output handling when content reaches the browser.
  • Avoiding unsafe rendering operations elsewhere in the application.

Do not assume that data is safe simply because it came from your own database.

Use Content Security Policy as an Additional Layer

Content Security Policy (CSP) is a browser security mechanism that can help reduce the impact of certain XSS attacks.

A CSP allows you to define which sources of scripts, styles, images, and other resources the browser may load or execute.

For example, a policy can restrict JavaScript execution to approved sources and disallow inline scripts.

A basic illustrative policy might look like this:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';

This example is a starting point, not a complete policy for every application. Your actual configuration may need additional directives for images, styles, API connections, and other resources.

CSP is most effective when designed and tested for your application. Consider nonces or hashes where inline scripts are genuinely required, and avoid weakening the policy with unsafe directives simply to make existing code work.

Important: CSP is an additional security layer, not a replacement for sanitization. Continue to sanitize untrusted HTML and use safe DOM APIs.

Consider Trusted Types for Sensitive Applications

Trusted Types is a browser security feature that can help prevent unsafe strings from reaching sensitive DOM injection points.

For example, assigning an ordinary string to innerHTML can be restricted by a Trusted Types enforcement policy in supported browsers.

A policy can be created to sanitize HTML before producing a TrustedHTML value.

When using DOMPurify with Trusted Types, follow the library’s current documentation and configure the integration carefully. Trusted Types policies must not simply return untrusted input unchanged.

This feature can be particularly valuable in larger applications where many developers work with DOM APIs and it is difficult to review every assignment manually.

However, Trusted Types does not automatically make all user input safe. It works best when combined with a deliberate sanitization policy and secure coding practices.

Common Mistakes to Avoid

Even when developers understand the basics of XSS, several mistakes can leave applications vulnerable.

1. Rendering User Input Directly

Avoid inserting untrusted input into innerHTML without sanitization.

element.innerHTML = userInput;

Use textContent for plain text or a suitable sanitizer for supported HTML.

2. Trusting Content from a Database

Stored content can still be malicious. An attacker may have submitted it through a modified request, an outdated form, or a compromised account.

Treat stored user-generated HTML as untrusted until it has been processed according to your security policy.

3. Sanitizing Only with Regular Expressions

Removing <script> elements does not remove every possible XSS vector. Use a proper HTML sanitizer instead.

4. Allowing Every HTML Attribute

Attributes such as event handlers can introduce executable behavior. Restrict the allowed attributes to the ones your application actually needs.

5. Modifying Sanitized HTML Unsafely

If you sanitize content and then concatenate untrusted strings into the result, you may reintroduce the same vulnerability you were trying to prevent.

Avoid unsafe post-sanitization transformations. If content changes, sanitize the final HTML again before rendering it.

6. Ignoring Links and Embedded Content

URLs, iframe elements, SVG markup, and embedded resources require careful handling. Allow only the features your application genuinely needs.

7. Failing to Update Security Libraries

Sanitizers evolve as security issues are discovered and browser behavior changes. Keep dependencies updated, review security advisories, and test your configuration after upgrades.

How to Test Your HTML Rendering Safely

Testing helps verify that your application preserves useful formatting without allowing dangerous markup to execute.

Start with ordinary HTML:

<p>Hello, world!</p>
<strong>Important message</strong>
<a href="https://example.com">Example link</a>

Confirm that the supported elements render correctly.

Next, test potentially dangerous input:

<img src="x" onerror="alert('XSS')">
<svg onload="alert('XSS')"></svg>
<a href="javascript:alert('XSS')">Unsafe link</a>

For each test, verify that the dangerous behavior is blocked and that the output follows your application’s HTML policy.

Do not rely only on visual inspection. An element may look harmless while containing an unsafe attribute or URL.

You should also test malformed markup, unexpected attributes, nested elements, and encoded input. Consider automated security tests and code reviews for functions that use innerHTML, outerHTML, or other DOM injection APIs.

Run tests in a controlled development environment and confirm that legitimate formatting remains intact.

A Practical Best-Practice Example

The following example combines safe plain-text rendering with sanitized HTML rendering.

import DOMPurify from "dompurify";

function renderUserText(element, text) {
  element.textContent = text;
}

function renderUserHTML(element, html) {
  const cleanHTML = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: [
      "p",
      "br",
      "strong",
      "em",
      "ul",
      "ol",
      "li",
      "a"
    ],
    ALLOWED_ATTR: [
      "href",
      "title"
    ]
  });

  element.innerHTML = cleanHTML;
}

const messageElement = document.querySelector("#message");
const articleElement = document.querySelector("#article");

renderUserText(
  messageElement,
  "Welcome to our website!"
);

renderUserHTML(
  articleElement,
  "<p>Welcome to <strong>our website</strong>!</p>"
);

In this example, ordinary messages are rendered as text, while formatted article content passes through DOMPurify.

This separation makes the intended behavior clearer and reduces the risk of accidentally interpreting plain-text input as HTML.

For a production application, also review URL requirements, the sanitizer’s version, the server-side content workflow, and any additional HTML features that your editor supports.

Frequently Asked Questions (FAQs)

1. How do I safely render user-generated HTML in JavaScript?

Use a maintained HTML sanitizer such as DOMPurify before inserting user-generated HTML into the DOM. If formatting is unnecessary, use textContent instead of innerHTML.

2. Is innerHTML safe for user input?

No, not when used directly with untrusted input. The browser interprets strings assigned to innerHTML as HTML, which may introduce XSS vulnerabilities. If you need HTML rendering, sanitize the input first and follow a carefully defined policy.

3. What is the best JavaScript library for sanitizing HTML?

DOMPurify is a widely used option for sanitizing HTML in browser-based JavaScript applications. It supports configurable allowlists and removes many dangerous HTML constructs. Choose a maintained version and review its documentation for your environment.

4. Does textContent prevent XSS?

Using textContent to display untrusted data as text avoids interpreting that data as HTML, which prevents a common class of DOM-based XSS vulnerabilities. However, it does not protect other unsafe operations elsewhere in the application.

5. Can I sanitize HTML with regular expressions?

Regular expressions are not a reliable substitute for an HTML sanitizer. HTML parsing is complex, and dangerous markup can take forms that simple patterns do not handle correctly. Use a purpose-built library.

6. Should I sanitize user-generated HTML on the client or server?

Depending on your architecture, server-side sanitization can help protect stored content, while safe client-side rendering protects the browser insertion point. The right approach depends on where the content is processed and how it is used. Neither layer should blindly trust untrusted HTML.

7. Does DOMPurify remove all dangerous HTML?

DOMPurify is designed to remove many dangerous HTML constructs, but security depends on the version, configuration, environment, and subsequent handling of the sanitized output. Keep it updated, avoid unsafe customizations, and do not reintroduce untrusted content after sanitization.

8. What is the difference between HTML escaping and HTML sanitization?

HTML escaping converts special characters into representations that the browser displays as text rather than interpreting as markup. HTML sanitization examines HTML and removes or restricts potentially dangerous elements, attributes, and values while preserving approved formatting. Use escaping for plain text and sanitization when HTML must be rendered.

9. Can Content Security Policy replace HTML sanitization?

No. CSP can reduce the impact of certain attacks, but it is not a replacement for safe DOM operations or HTML sanitization. Use it as one layer in a broader security strategy.

10. Is user-generated HTML from a rich-text editor safe?

Not automatically. A rich-text editor may restrict ordinary user actions, but attackers can bypass the interface and submit modified requests. Treat submitted HTML as untrusted and sanitize it before rendering.

Key Takeaways

  • Use textContent for plain text: It prevents the browser from interpreting user input as HTML.
  • Use DOMPurify for rich HTML: Sanitize content before inserting it into the DOM.
  • Define an HTML allowlist: Permit only the elements and attributes your application requires.
  • Validate URLs carefully: Restrict URL schemes and avoid unsafe link destinations.
  • Do not rely on regular expressions: Use a dedicated HTML sanitizer instead.
  • Treat database content as untrusted: Stored content can still contain malicious markup.
  • Avoid unsafe post-sanitization changes: Do not concatenate untrusted strings into sanitized HTML.
  • Add security layers: Consider CSP and Trusted Types where appropriate.
  • Keep dependencies updated: Review security advisories and test sanitizer behavior regularly.
  • Test real-world attack patterns: Verify that dangerous markup is blocked without breaking legitimate formatting.

Conclusion

Safely rendering user-generated HTML in JavaScript requires more than removing a few suspicious tags. You need to understand how browsers interpret HTML, choose safe DOM APIs, and apply a consistent sanitization policy whenever formatted user content must be displayed.

For plain text, textContent is usually the simplest and safest choice. When your application needs rich-text formatting, DOMPurify provides a practical way to sanitize HTML before rendering it.

Combine these techniques with careful URL handling, regular security testing, updated dependencies, and additional protections such as CSP and Trusted Types where appropriate.

By following these practices, you can support useful user-generated formatting while significantly reducing the risk of Cross-Site Scripting vulnerabilities in your JavaScript application.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *