How to avoid XSS vulnerabilities when using HTML string interpolation in JavaScript
Problem
I was building a user profile card and thought I could make it cleaner with template literals:
const name = getUserInput();const element = document.querySelector("#output");
element.innerHTML = ` <div class='user-card'> <h3>Hello, ${name}!</h3> </div>`;I assumed this would just render the user’s name. But when a tester tried entering <img src='x' onerror='alert(1)'>, my browser showed an alert popup.
This is a cross-site scripting (XSS) vulnerability. The onerror event handler executed malicious JavaScript code.
Why this happens
Template literals don’t escape HTML entities. They just insert the string as-is into your HTML. When the browser parses the HTML, it recognizes the <img> tag and the onerror attribute. Since the image source is invalid (src='x'), the error handler triggers.
I thought script tags were the only risk, but browsers block direct script execution from innerHTML. Attackers bypass this limitation using event handlers on other elements.
Here’s a flow showing what happens:
User Input → "<img src='x' onerror='alert(1)'>" ↓Template Literal → Inserted into HTML string ↓innerHTML Assignment → Browser parses HTML ↓Image element created → src='x' fails ↓onerror triggers → alert(1) executesI discovered other dangerous payloads too:
<svg onload='alert(1)'>- SVG tags execute scripts on load<iframe src='javascript:alert(1)'>- iframes can execute JavaScript<details open ontoggle='alert(1)'>- details elements have toggle events
The problem isn’t just malicious users. API responses, localStorage, or even trusted databases can contain data that gets contaminated later.
How I fixed it
First approach: Use textContent
The simplest fix is textContent for plain text:
const name = getUserInput();const element = document.querySelector("#output");
element.textContent = `Hello, ${name}!`;textContent treats everything as literal text. Special characters get escaped automatically. The same input renders as: Hello, <img src='x' onerror='alert(1)'>!
This works when you only need text. But sometimes I need to render actual HTML elements.
Second approach: Escape HTML manually
I tried building my own escape function:
function escapeHTML(str) { return str .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");}
const name = escapeHTML(getUserInput());element.innerHTML = `<h3>Hello, ${name}!</h3>`;This catches < and > tags, but I forgot about attributes. If the input contains a single quote, it can break out of my HTML string.
Third approach: Use Trusted Types with DOMPurify
Modern browsers support Trusted Types API. This enforces that all innerHTML assignments go through a sanitization policy:
// Create a policy that sanitizes HTMLconst policy = trustedTypes.createPolicy("sanitize-policy", { createHTML: (input) => DOMPurify.sanitize(input)});
const name = getUserInput();const safeHTML = policy.createHTML(`<h3>Hello, ${name}!</h3>`);element.innerHTML = safeHTML;DOMPurify removes dangerous elements like script tags, event handlers, and dangerous attributes. It keeps safe HTML like <b>, <p>, and <div>.
I also added a Content Security Policy header:
Content-Security-Policy: require-trusted-types-for 'script'This header forces the browser to block any innerHTML assignment that doesn’t use a Trusted Type. If I forget to sanitize, the browser throws an error instead of executing malicious code.
Fourth approach: Create elements programmatically
For complex dynamic content, I build DOM elements instead of using strings:
function createUserCard(name, email) { const card = document.createElement("div"); card.className = "user-card";
const h3 = document.createElement("h3"); h3.textContent = name; // Safe
const p = document.createElement("p"); p.textContent = email; // Safe
card.append(h3, p); return card;}
const card = createUserCard(getUserInput(), getEmail());element.appendChild(card);This approach is verbose but completely safe. I use textContent for all user data and only set attributes with trusted values.
Common mistakes I avoided
I almost made several errors:
-
Assuming template literals are safer than string concatenation - They aren’t. Both insert raw strings.
-
Only escaping
<and>- Single quotes can break out of attribute values, allowing tag injection. -
Trusting localStorage data - Data stored in localStorage can be modified by browser extensions or compromised devices.
-
Using
innerHTML +=for updates - This re-parses all existing content, loses event listeners, and increases XSS surface area. -
Not implementing CSP - Content Security Policy is a crucial defense layer. Even if my code has bugs, CSP can block execution.
Why this matters
XSS attacks can:
- Steal session cookies and hijack user accounts
- Perform actions on behalf of authenticated users
- Redirect users to phishing sites
- Expose sensitive data to attackers
XSS has been in the OWASP Top 10 security risks for years (currently A03: Injection). It’s a foundational security concept.
Summary
In this post, I showed why HTML string interpolation in JavaScript creates XSS vulnerabilities and how to fix it. The key point is to never trust data that might be untrusted. Use textContent for plain text, Trusted Types with DOMPurify for HTML, or build elements programmatically.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 OWASP XSS Prevention Cheat Sheet
- 👨💻 MDN: Cross-site scripting (XSS)
- 👨💻 Trusted Types API - MDN
- 👨💻 DOMPurify Library
- 👨💻 Content Security Policy (CSP) - MDN
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments