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><script>alert(1)</script></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
unsafeHTMLorunsafeSVGused anywhere? - If yes, is sanitization local, obvious, and tested?
- Are any URL attributes bound from user-controlled data?
- Are protocols restricted to
http:andhttps:where appropriate? - Is there any
innerHTML,outerHTML, orinsertAdjacentHTMLusage? - 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.