Output Encoding
Output encoding turns dangerous characters into safe ones at the moment of rendering. Different contexts (HTML body, attribute, JS, URL, CSS) need different encodings — one-size-fits-all gets you XSS.
Per-context encoding, library defaults
EXAMPLE
// 1) HTML body — escape & < > " '
function encodeHtml(s) {
return String(s)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
// In a template:
// <p>${encodeHtml(userInput)}</p>
// 2) HTML attribute — quoted attributes still need & / "
// <input value="${encodeHtml(userInput)}">
// Unquoted attributes are dangerous — never emit one with user data
// 3) JavaScript string — escape backslash, quote, control chars
function encodeJsString(s) {
return String(s).replace(/[\\\"'<>\u0000-\u001f\u2028\u2029]/g, c => {
const code = c.charCodeAt(0).toString(16).padStart(4, '0');
return `\\u${code}`;
});
}
// <script>const name = "${encodeJsString(userName)}";</script>
// Better: emit JSON.stringify(value) — it handles all of this
// <script>const data = ${JSON.stringify(payload)};</script>
// 4) URL components — encodeURIComponent for query-string values
const safe = `?q=${encodeURIComponent(userQuery)}`;
// Don't use encodeURI for parts; it's too lenient.
// 5) CSS — least-safe context, avoid putting user data in CSS at all
// If you must:
function encodeCss(s) {
return s.replace(/[^\w-]/g, c => `\\${c.charCodeAt(0).toString(16)} `);
}
// 6) Framework defaults — most frameworks encode HTML by default
// React
<div>{userInput}</div> // SAFE — auto-escaped
<div dangerouslySetInnerHTML={{ __html: dirtyHtml }} /> // ONLY if you sanitised with DOMPurify
// Vue
<p>{{ userInput }}</p> // SAFE — interpolation escapes
<p v-html="userInput"></p> // DANGEROUS unless sanitised
// Svelte
<p>{userInput}</p> // SAFE
{@html userInput} // unsafe — sanitise first
// Angular
<p>{{ userInput }}</p> // SAFE
<div [innerHTML]="userInput"></div> // Angular's DomSanitizer strips known-bad by default
// Blade (Laravel)
{{ $userInput }} // SAFE — htmlspecialchars()
{!! $userInput !!} // DANGEROUS — raw output
// 7) JSON in templates — use the framework helper
// Rails: <script>const d = <%= raw payload.to_json %></script> // bad
// Rails better: <script>const d = <%= json_escape(payload.to_json) %></script>
// Laravel: <script>const d = @json($payload)</script>
// Django: <script>const d = {{ payload|json_script:"d" }}</script> + parse from <script id="d">
// 8) Common mistakes
// • One encoding for all contexts (e.g. just `<`) — fails in JS / URL / CSS
// • Double-encoding (data already encoded gets encoded again — &amp;lt;)
// • Stripping characters instead of encoding — UX worse, still buggy
// • Encoding on STORE — once you decide on the storage form, encoding still happens
// at output time per context
// 9) Test for XSS — combined with output encoding
// Common payloads:
// <script>alert(1)</script>
// <img src=x onerror=alert(1)>
// "; alert(1); //
// javascript:alert(1)
// If your output encoder is right, none of these should execute when reflected.
// 10) Defense in depth — encoding + CSP
// Even with perfect encoding, add a strict CSP (nonce, no unsafe-inline) so a missed
// spot can't execute injected JS. See the csp lesson for the recipe.
// 11) Tools
// - DOMPurify — when you must accept rich HTML
// - js-string-escape — robust JS string encoder
// - he — html entity encoder/decoder
Why it matters
Encoding is per-context. The same value in <p>, onclick=, href=, and <script> needs four different escapes — that’s why frameworks bake context awareness into their templating engines.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// HTML context
encodeURIComponent(...) // for URL params
template `${escapeHtml(v)}` // for HTML text
// Modern frameworks (React/Vue/Svelte/Blade/Razor) escape by default;
// the bug is when you reach for an "unsafe" / "raw" API.
Try it Yourself »
Discussion
Loading…