Lit does a lot of the right things by default. That’s the good news.

The bad news is that teams get comfortable, then punch straight through Lit’s safety model with unsafeHTML, sloppy URL binding, or “just this one” direct DOM write. I’ve seen this happen in otherwise solid codebases.

If you build web components with Lit, here’s the reference guide I’d want next to me during code review.

The short version

Lit escapes text interpolations in templates:

html`<div>${userInput}</div>`

That is safe for HTML context. If userInput is "<img src=x onerror=alert(1)>", Lit renders it as text, not markup.

Where people get into trouble:

  • unsafeHTML(...)
  • unsafeSVG(...)
  • writing to innerHTML
  • building dangerous URLs from untrusted input
  • assuming every attribute/property binding is equally safe
  • rendering attacker-controlled content inside scriptable contexts like SVG
  • trusting third-party data because “it came from our API”

Safe by default: normal text interpolation

This is the baseline pattern you should prefer.

import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('user-card')
export class UserCard extends LitElement {
  @property() bio = '';

  render() {
    return html`
      <p>${this.bio}</p>
    `;
  }
}

If bio contains HTML or script-looking junk, Lit escapes it.

Rendered output becomes effectively:

<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>

That’s exactly what you want.

The biggest footgun: unsafeHTML

unsafeHTML exists for cases where you really do want to render HTML from a string. The name is not subtle. Treat it like handling raw SQL.

Vulnerable

import {LitElement, html} from 'lit';
import {unsafeHTML} from 'lit/directives/unsafe-html.js';

export class CommentView extends LitElement {
  static properties = {
    commentHtml: {}
  };

  commentHtml = '';

  render() {
    return html`
      <div class="comment">${unsafeHTML(this.commentHtml)}</div>
    `;
  }
}

If commentHtml is attacker-controlled, game over.

Safer option: don’t render HTML at all

render() {
  return html`
    <div class="comment">${this.commentHtml}</div>
  `;
}

If you absolutely must render HTML, sanitize first

Use a proven sanitizer like DOMPurify.

import {LitElement, html} from 'lit';
import {unsafeHTML} from 'lit/directives/unsafe-html.js';
import DOMPurify from 'dompurify';

export class CommentView extends LitElement {
  static properties = {
    commentHtml: {}
  };

  commentHtml = '';

  render() {
    const clean = DOMPurify.sanitize(this.commentHtml, {
      USE_PROFILES: {html: true}
    });

    return html`
      <div class="comment">${unsafeHTML(clean)}</div>
    `;
  }
}

My rule is simple: if I see unsafeHTML, I expect to also see sanitization right next to it, not somewhere upstream that “should have happened.”

unsafeSVG is even riskier than people think

SVG is not just “images in XML.” It has a long history of scriptable features and weird parsing behavior.

Risky

import {unsafeSVG} from 'lit/directives/unsafe-svg.js';

render() {
  return html`${unsafeSVG(this.iconMarkup)}`;
}

If iconMarkup is untrusted, don’t do this.

Better

Use a fixed icon map:

const icons: Record<string, unknown> = {
  check: html`<svg viewBox="0 0 16 16"><path d="..."></path></svg>`,
  close: html`<svg viewBox="0 0 16 16"><path d="..."></path></svg>`
};

render() {
  return html`${icons[this.iconName] ?? ''}`;
}

Whitelist known templates. Don’t accept arbitrary SVG strings.

Attribute binding vs property binding

Lit supports both:

html`<img alt="${text}">`
html`<input .value=${text}>`

For plain text data, both are generally fine. But context matters.

Safe text attribute

html`<div title=${userInput}></div>`

Lit escapes attribute values correctly.

Dangerous URL attribute

html`<a href=${userProfileUrl}>Profile</a>`

If userProfileUrl can be javascript:alert(1), you have an XSS problem when the link is clicked.

Lit does not magically validate URL schemes for you.

Safer URL handling

Validate allowed protocols before binding:

function safeUrl(input: string): string {
  try {
    const url = new URL(input, window.location.origin);
    if (url.protocol === 'http:' || url.protocol === 'https:') {
      return url.toString();
    }
  } catch {}
  return '#';
}

render() {
  return html`<a href=${safeUrl(this.userProfileUrl)}>Profile</a>`;
}

For image sources, iframe sources, form actions, and similar URL-bearing attributes, do the same kind of validation.

Don’t assemble HTML with string concatenation

This is old-school XSS bait, even inside Lit components.

Bad

firstUpdated() {
  this.renderRoot.querySelector('.output')!.innerHTML =
    `<p>${this.message}</p>`;
}

Even if the component uses Lit elsewhere, this bypasses Lit’s escaping.

Good

Keep rendering inside Lit’s template system:

render() {
  return html`<div class="output"><p>${this.message}</p></div>`;
}

Or if you truly need imperative DOM updates, use text nodes:

firstUpdated() {
  const output = this.renderRoot.querySelector('.output')!;
  output.textContent = this.message;
}

Event handlers: mostly safer, but don’t get clever

Lit event listeners use the @event=${handler} syntax:

html`<button @click=${this.handleClick}>Save</button>`

That is good. You are passing a function, not an inline JavaScript string.

What you should not do is generate inline event attributes through unsafe HTML:

html`${unsafeHTML(`<button onclick="${userData}">Click</button>`)}`

That reintroduces classic DOM XSS.

Stick to real listener bindings.

Watch custom directives and third-party components

A lot of teams audit their own Lit templates and forget about wrappers, directives, markdown renderers, WYSIWYG outputs, and shared design system components.

If a component accepts a property like this:

<rich-content .htmlContent=${cmsBody}></rich-content>

go inspect what rich-content actually does. If it calls unsafeHTML or innerHTML, your parent component is not magically safe.

This is also where response headers matter. A strong Content Security Policy can reduce blast radius when something slips through. If you need practical CSP setup details, csp-guide.com is a useful reference. And if you want to sanity-check your site’s headers in the wild, I’d use HeaderTest.

Trusted Types: worth using if you have unsafeHTML

If your app is large enough to have recurring HTML injection points, Trusted Types is one of the few browser features that actually changes developer behavior for the better.

With Trusted Types enforcement, unsafe sinks like innerHTML can be locked down unless the value comes from an approved policy. That won’t fix bad sanitization, but it stops random accidental DOM XSS.

A rough pattern looks like this:

const policy = window.trustedTypes?.createPolicy('app-policy', {
  createHTML(input: string) {
    return DOMPurify.sanitize(input, {USE_PROFILES: {html: true}});
  }
});

Then only pass policy-produced HTML to sinks that require it.

If you’re using CSP, Trusted Types fits naturally into that setup.

Patterns I recommend in real projects

1. Default to text, not HTML

html`<div>${content}</div>`

If product asks for rich text, make them justify it.

2. Centralize sanitization

import DOMPurify from 'dompurify';

export function sanitizeRichHtml(input: string): string {
  return DOMPurify.sanitize(input, {
    USE_PROFILES: {html: true},
    FORBID_TAGS: ['script', 'style'],
    FORBID_ATTR: ['onerror', 'onclick', 'onload']
  });
}

Then use it consistently:

html`<section>${unsafeHTML(sanitizeRichHtml(this.body))}</section>`

3. Validate URLs with allowlists

function safeExternalUrl(input: string): string {
  try {
    const u = new URL(input);
    return ['https:', 'http:'].includes(u.protocol) ? u.toString() : '#';
  } catch {
    return '#';
  }
}

4. Ban direct innerHTML in component code

If I were setting team rules, I’d lint against it.

5. Treat CMS and API content as untrusted

I don’t care if the content came from “our backend.” If a user, partner, or admin could have influenced it, treat it as hostile.

Quick review checklist for Lit XSS

Use this during PR review:

  • Are all untrusted values rendered via normal Lit interpolation?
  • Is unsafeHTML or unsafeSVG used anywhere?
  • If yes, is sanitization local, obvious, and tested?
  • Are any URL attributes bound from user-controlled data?
  • Are protocols restricted to http: and https: where appropriate?
  • Is there any innerHTML, outerHTML, or insertAdjacentHTML usage?
  • Do child components or directives inject raw HTML?
  • Is CSP enabled?
  • Is Trusted Types worth enforcing for this app?

Safe and unsafe examples side by side

Safe

html`<p>${userComment}</p>`

Unsafe

html`${unsafeHTML(userComment)}`

Safe with sanitization

html`${unsafeHTML(DOMPurify.sanitize(userComment))}`

Unsafe URL

html`<a href=${userSuppliedUrl}>Open</a>`

Safer URL

html`<a href=${safeUrl(userSuppliedUrl)}>Open</a>`

Unsafe imperative update

element.innerHTML = userBio;

Safe imperative update

element.textContent = userBio;

Lit gives you a safer default than most frontend stacks had a few years ago. That helps a lot. But the moment you leave normal template interpolation, you’re back in classic XSS territory. That’s the line I’d keep in my head: Lit is safe by default, not safe no matter what.