Safe DOM Rendering Patterns in JavaScript
Learn safe DOM rendering patterns in JavaScript. Master textContent, createElement, safe attribute handling, HTML sanitization, and CSP to prevent injection attacks.
Every time JavaScript inserts dynamic content into the DOM, there is a choice: a safe API that treats the content as data, or a dangerous API that treats it as code. The difference is whether user input gets rendered as plain text or gets parsed as HTML.
The safe path is always to use APIs that never interpret strings as HTML. Picking the right API for the job means understanding which DOM methods parse strings and which treat them as inert text. This article covers the safe rendering patterns for every DOM insertion scenario, building on fundamental DOM selection and manipulation skills with security in mind.
The Golden Rule: Content vs Code
The decision tree is simple. If the string should be text, use textContent. If you need HTML structure, use createElement and build nodes. If you must render user-provided HTML, sanitize it first. Never let an unsanitized string cross the boundary from data to code.
Pattern 1: Rendering Text Content
The most common rendering task is inserting text. The safe way is textContent:
// Unsafe
document.getElementById("username").innerHTML = userProfile.name;
// Safe
document.getElementById("username").textContent = userProfile.name;textContent treats the value as plain text regardless of what it contains. HTML tags, script blocks, and event handlers are displayed as literal characters. There is no way to inject HTML through textContent.
A worked example with a truly malicious input:
const maliciousName = `<img src=x onerror="alert('stolen: ' + document.cookie)">`;
// Using innerHTML: the script executes
el.innerHTML = maliciousName; // VULNERABLE
// Using textContent: the script is displayed as text
el.textContent = maliciousName; // Safe -- user sees the angle bracketsinnerText is also safe and behaves similarly, but it triggers a layout recalculation. Prefer textContent for performance.
Pattern 2: Building HTML Structure Safely
When you need actual HTML elements, build them programmatically with createElement and setAttribute:
// Unsafe: building HTML with string concatenation
function renderCommentBad(comment) {
return `
<div class="comment">
<strong>${comment.author}</strong>
<p>${comment.body}</p>
</div>
`;
}
container.innerHTML += renderCommentBad(comment);
// Safe: building DOM nodes directly
function renderCommentSafe(comment) {
const div = document.createElement("div");
div.className = "comment";
const strong = document.createElement("strong");
strong.textContent = comment.author;
div.appendChild(strong);
const p = document.createElement("p");
p.textContent = comment.body;
div.appendChild(p);
return div;
}
container.appendChild(renderCommentSafe(comment));The safe version never concatenates user data into an HTML string. Every piece of user content is assigned through textContent on an element created by createElement. The browser handles all escaping internally.
Pattern 3: Setting Attributes Safely
Attributes have their own injection risks. The setAttribute method is safe for most attributes, but be careful with event handlers and URL-bearing attributes:
// Safe: plain attributes
element.setAttribute("data-id", userInput); // Safe
element.setAttribute("class", userInput); // Safe
element.setAttribute("title", userInput); // Safe (text attribute)
// Unsafe: event handler attributes
element.setAttribute("onclick", userInput); // VULNERABLE
// Unsafe: URL attributes with untrusted values
element.setAttribute("href", userInput); // VULNERABLE (javascript: URL)
element.setAttribute("src", userInput); // VULNERABLE (javascript: URL)For event handlers, always use addEventListener:
// Safe
element.addEventListener("click", myHandler);
// Never do this
element.setAttribute("onclick", "myHandler()");For URL attributes (href, src, action, formaction), validate that the value is a safe URL:
function safeSetHref(element, url) {
// Only allow http:, https:, and relative URLs
if (url.startsWith("http:") || url.startsWith("https:") || url.startsWith("/")) {
element.href = url;
} else {
element.href = "#";
console.warn("Blocked unsafe URL:", url);
}
}The javascript: URL scheme is executable. Never let untrusted input set an href or src without validation.
Pattern 4: Rendering Rich HTML with Sanitization
When you must render user-provided HTML, a text editor's content or an email body, use DOMPurify:
import DOMPurify from "dompurify";
function renderRichContent(htmlString) {
const clean = DOMPurify.sanitize(htmlString, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li"],
ALLOWED_ATTR: ["href", "target"]
});
document.getElementById("content").innerHTML = clean;
}DOMPurify parses the HTML, walks every node, and removes anything dangerous: script tags, event handler attributes (onerror, onload, onclick), javascript: URLs, and HTML comments that could hide malicious code.
Configure the allowed tags and attributes to the minimum your application needs. If your users only need bold and italic, do not allow a tags or img tags.
Pattern 5: Rendering into Different Contexts
Different parts of the DOM need different escaping. A string safe for an HTML body might be dangerous in an attribute or inside a script tag:
// HTML body context: escape < > & " '
function escapeHtml(str) {
return str.replace(/[&<>"']/g, char => ({
"&": "&", "<": "<", ">": ">",
'"': """, "'": "'"
})[char]);
}
// URL parameter context: encodeURIComponent
function buildUrl(base, params) {
const qs = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
return `${base}?${qs}`;
}
// CSS context: very limited safe usage
// Prefer setting individual style properties instead
element.style.color = userColor; // Validate the color value firstThe table of escaping by context:
| Context | Escaping method | Example |
|---|---|---|
| HTML body | textContent or HTML entity encoding | <div>USER</div> |
| HTML attribute | setAttribute or quote and encode | title="USER" |
| URL query string | encodeURIComponent | ?q=USER |
| URL path | encodeURI (preserves slashes) | /search/USER |
| CSS values | Validate against allowlist | color: USER |
| JavaScript string | JSON.stringify | const x = "USER" |
Never mix contexts. HTML escaping does not protect a value inside a URL. URL encoding does not protect a value inside HTML. Each context has its own rules.
Pattern 6: Content Security Policy as a Safety Net
Even with careful escaping, mistakes happen. A Content Security Policy is the final defense:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
object-src 'none';
base-uri 'self';
form-action 'self';This policy means: even if an attacker injects <script>alert(1)</script> into the page through a missed escape, the browser does not execute it. CSP blocks inline scripts unless they have a matching nonce or hash.
For deeper coverage of CSP and injection prevention, see the XSS prevention guide.
Common Mistakes
Using innerHTML for simple text. The most common and most dangerous pattern. Every innerHTML = userString is a potential XSS. Use textContent.
Calling a sanitizer but then modifying the result. If you sanitize HTML, then do string operations on it, you can reintroduce vulnerabilities. Sanitize last, right before insertion.
Trusting sanitized output in attributes. DOMPurify cleans HTML elements and their attributes. But if you extract an href from sanitized HTML and set it with setAttribute("href", value), validate the URL separately.
Forgetting that JSON data can contain HTML. An API response like {"name": "<script>..."} is valid JSON. When you render that name into the DOM, escape it.
Rune AI
Key Insights
- Use textContent for text, createElement for HTML structure, never innerHTML with untrusted strings.
- Escape for the right context: HTML entities, URL encoding, and CSS escaping are all different.
- Sanitize HTML with DOMPurify when you must render user-provided rich text.
- A Content Security Policy is your safety net if rendering defenses fail.
- Every rendering path (HTML, attributes, URLs, CSS, JavaScript) has its own escaping rules.
Frequently Asked Questions
Is textContent always safe?
Can I safely use innerHTML with a sanitizer?
Is createElement safer than innerHTML?
Conclusion
Safe DOM rendering is about using the right API for the right context. textContent for text, createElement for structure, setAttribute for attributes, and DOMPurify when you must render HTML. Combine these with a Content Security Policy, and user-generated content becomes safe to display.Safe DOM rendering is about choosing the right API for every insertion. textContent for text. createElement for structure. setAttribute with validation for attributes. DOMPurify for rich HTML. CSP as the safety net.
The mental model is simple: every string that crosses from JavaScript into the DOM must go through one of these safe paths. There is no such thing as "probably safe" user input. If it came from outside your code, treat it as hostile and render it safely.
The safest code is the code that never calls innerHTML, outerHTML, or document.write. Build the habit of reaching for textContent and createElement first. They are not just safe. They make your intent clearer.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.