Cross-site scripting (XSS) lets untrusted data run as script in another user's browser, inside your origin and with that user's session, and it remains one of the most frequently reported web weaknesses. OWASP counts more than 30,000 CVEs for XSS in the Top 10:2025 Injection category, and CISA and the FBI have issued a Secure by Design Alert asking software makers to eliminate the class rather than patch instances. This guide goes deeper than the overview in the Web Application Attacks Guide: it explains how stored, reflected, DOM-based, blind and mutation XSS differ, where each fix belongs, how to run HTML sanitisation and Trusted Types safely, and how to test, detect and measure progress. It is for application security engineers, front-end and back-end developers and the reviewers who sign off releases, and it is written from the defender's side, with no payloads or attack procedures.
Key takeaways
- Fix XSS where the data is rendered. Contextual output encoding at the last step before the browser parses the data, done by the template engine or a vetted encoder, removes most server-side XSS; input filtering does not.
- DOM-based XSS needs browser-side controls. The server may never see the data, so safe DOM APIs, a reviewed set of sinks and Trusted Types are the defences, and server-side scanners and WAFs largely miss it.
- Sanitise rich HTML once, then leave it alone. Use a maintained HTML sanitiser with an allowlist configuration and never modify its output; mutation XSS exploits the gap between what a sanitiser parsed and what the browser later builds.
- CSP limits damage, it does not fix bugs. A strict nonce or hash policy blocks most injected script, but allowlist policies are routinely bypassed, so CSP sits behind encoding and sanitisation, never in front.
- Blind XSS targets your staff. Payloads stored in tickets, logs and admin consoles fire in privileged sessions; back-office tools need the same encoding, sanitisation and CSP as customer-facing pages.
How XSS works and the main variants
CWE-79, Improper Neutralization of Input During Web Page Generation, describes the root cause of every variant: data from an untrusted source ends up in a page without being neutralised for the context it lands in, so the browser treats it as markup or code. The script runs in your origin, so it can read what the page reads, call your APIs with the user's cookies and rewrite what the user sees. OWASP rates XSS high frequency and relatively low impact, but one stored XSS in an admin console can hand over a platform.
| Variant | Where the malicious data lives | Who it hits | Where the fix belongs | Main test reference |
|---|---|---|---|---|
| Reflected XSS (non-persistent, CWE-79 Type 1) | In the request, echoed straight back in the response | A user who follows a crafted link or submits a crafted form | Server-side output encoding in templates | WSTG-INPV-01 |
| Stored XSS (persistent, Type 2) | Saved in a database, file, log or message and rendered later | Every user who views the content, often administrators | Encoding at render time; sanitisation for rich text | WSTG-INPV-02 |
| DOM-based XSS (client-side XSS, Type 0) | Read by JavaScript from the URL, fragment, storage or messages and written to a sink | Users of the single-page app or script | Safe DOM APIs, Trusted Types, client-side sanitiser | WSTG-CLNT-01 |
| Blind XSS | Stored in a place the attacker never sees, such as a support queue or log viewer | Support agents, moderators, administrators | Encoding and CSP in internal and third-party back-office tools | Stored XSS tests with out-of-band confirmation |
| Self-XSS | Typed or pasted by the victim, usually after social engineering | The person persuaded to paste it | User warnings in consoles; fix as a real bug if any other user can trigger it | WSTG-CLNT-01.1 |
| Mutation XSS (mXSS) | Markup that is harmless as sent but changes when the browser parses and reserialises it | Users of pages that sanitise and then reinsert HTML | Sanitise in the same parser context as insertion; keep sanitisers patched | Sanitiser regression tests |
| Universal XSS (UXSS) | A flaw in the browser or a browser extension, not in your application | Any site the affected browser visits | Browser and extension patching, extension allowlists | Out of application scope |
Stored XSS is harder to find than reflected because, as CWE-79 notes, injection and activation can be separated by minutes or days. WSTG has a separate test for self DOM-based XSS, and a "self" bug can become exploitable when chained with login CSRF. Universal cross-site scripting belongs to browser management; see the Enterprise Browser and Browser Security Guide.
Injection points teams miss
Most XSS reviews concentrate on form fields. The CWE-79 child entries and CAPEC patterns show where the rest come from.
- HTML attributes (CWE-83, CAPEC-243): unquoted values let data break out without angle brackets. The OWASP XSS Prevention Cheat Sheet says always quote attribute values, and attribute encoding alone does not make data safe inside event-handler attributes. Keep untrusted data out of event handlers and style attributes.
- XSS through HTTP headers (CAPEC-86): CWE-79 lists request headers as untrusted input, with observed cases through a crafted header and the Referer header in statistics and log analysis tools. Dashboards and log viewers must encode headers like any other input.
- XSS targeting error pages (CWE-81, CAPEC-198): error pages that repeat the requested path or parameter are a classic reflected source. Use auto-escaping error templates and keep request data out of error messages.
- XSS using MIME type mismatch (CAPEC-209): a browser that decides a file's real type differs from the declared one may run it as script. Send an accurate Content-Type and charset with X-Content-Type-Options: nosniff, serve uploads from a separate domain as attachments, and serve JSON as application/json, never text/html.
- XSS in Markdown: many renderers pass raw HTML through and accept any link scheme. ASVS 5.0 requirement 1.3.5 asks that user-supplied Markdown, CSS, XSL and BBCode be sanitised or disabled: turn off raw HTML, allow only http, https and mailto links, and sanitise the rendered output.
- SVG uploads: ASVS 1.3.4 requires user-supplied SVG to contain only safe tags and attributes, excluding scripts and foreignObject; rasterising removes the problem.
- "Internal" data: the cheat sheet says data from internal sources "should always be considered tainted"; apply that to partner feeds, CRM records and AI model output.
Context-specific output encoding in practice
The Secure Coding Guide sets out the encoding rule for each output context (HTML body, attribute, JavaScript, CSS, URL and DOM). This section covers where secure output encoding fails in real code bases even when developers know those rules.
- Encode last, decode once: ASVS 5.0 requirement 1.1.2 asks for encoding as the final step before the interpreter, and 1.1.1 for input to be decoded to canonical form only once. Encoding on the way into the database produces double-encoded text in some views and raw text in exports and emails.
- No global filters: the OWASP cheat sheet rejects interceptors that encode every request parameter: their encoding is "completely non-contextual", they double encode and they do nothing for DOM-based XSS.
- Know your escape hatches: auto-escaping stops at helpers such as React dangerouslySetInnerHTML, Angular bypassSecurityTrust methods, Django mark_safe and Lit unsafeHTML. React 19 blocks javascript: URLs, but this "does not replace application-specific URL validation". Lint each helper and require a reviewed justification.
- Allow only safe URL schemes: ASVS 1.2.2 requires blocking javascript: and data: URLs. Compare the parsed scheme against an allowlist; string prefix checks fail on case and whitespace variations.
- Keep data out of code: an encoded string later passed to eval or a similar sink "remains unsafe". Pass server data as JSON in a data attribute and read it with JSON.parse.
- Declare the character set: CWE-79 recommends an explicit character encoding on every page so the browser does not guess.
HTML sanitisation and mutation XSS
Encoding turns markup into text, which is wrong when users are meant to submit formatting, such as comments with links, knowledge base articles or email templates. Here the control is HTML sanitisation (HTML sanitization in US spelling): parse the markup, keep only allowlisted elements and attributes, and drop the rest. ASVS 1.3.1 requires untrusted HTML from WYSIWYG editors to be sanitised with "a well-known, secure sanitization library or framework feature".
| Sanitiser option | Strengths | Limits | Choose when |
|---|---|---|---|
| Browser HTML Sanitizer API (Element.setHTML) | Browser's own parser; safe methods "always remove XSS-unsafe elements and attributes" | MDN lists it as limited availability, not Baseline | Progressive enhancement now; default once supported browsers ship it |
| Maintained client-side sanitiser library | Same parser that renders the result, reducing mutation risk | Frequent patching; output must not be modified | Single-page apps inserting HTML |
| Server-side sanitiser library | Clean at rest for every consumer (web, email, export) | Server parser can differ from browsers, the root of mutation XSS | Content rendered in many places |
| Restricted markup language (Markdown with raw HTML off) | Small, predictable feature set | Renderer settings and link schemes still need review | Comments, tickets, documentation |
Mutation-based cross-site scripting was described by Heiderich and colleagues at ACM CCS 2013: markup that is harmless as sent changes when a browser parses it, serialises it through innerHTML and parses it again, so it passed server-side filters, client-side filters and WAF and IDS systems. The defences follow from the mechanism:
- Do not post-process sanitised HTML: OWASP warns that modifying sanitised content "can easily void your security efforts", including changes made by downstream libraries.
- Sanitise at insertion, in the target context: sanitising in the browser where the HTML is inserted, or with the context-aware Sanitizer API, removes the reparse step mXSS depends on.
- Prefer allowlist configurations: MDN says an allow configuration "is safer" than a remove configuration.
- Patch sanitisers as security dependencies: mutation bypasses are fixed in sanitiser releases, so track the library in software composition analysis.
DOM-based XSS and Trusted Types
The OWASP DOM based XSS Prevention Cheat Sheet puts it plainly: "DOM-based XSS arises from unsafe client-side processing." Data flows from a source (the URL and fragment, postMessage events, web storage, API responses) to an injection sink. Fragment data never reaches the server, so server-side encoding, WAFs and most scanners cannot see it. MDN groups sinks as HTML sinks (innerHTML, document.write), JavaScript sinks (eval) and JavaScript URL sinks (a script element's src).
DOM-based XSS prevention starts with safe APIs: textContent instead of innerHTML, setAttribute only for attributes that cannot execute code, JSON.parse instead of eval, and functions rather than strings passed to setTimeout, setInterval and event handler properties.
Trusted Types turns those rules into browser enforcement. With the CSP directive require-trusted-types-for 'script', a plain string passed to an injection sink throws a TypeError; only objects produced by a named policy are accepted. MDN marks the API "Baseline 2026 Newly available", across current browsers since February 2026, and OWASP says it "eliminates entire classes of DOM XSS rather than mitigating them".
- Inventory sinks: find sink use in first-party code and list third-party scripts that write HTML.
- Report first: send require-trusted-types-for 'script' in a Content-Security-Policy-Report-Only header for at least one release cycle.
- Refactor: replace string sinks with safe APIs; where HTML is genuinely needed, create one small policy whose createHTML calls your sanitiser.
- Default policy only for transition: MDN recommends it only while moving off legacy code, because it accepts strings at every sink.
- Enforce and pin names: enforce the directive and list allowed names in trusted-types so injected code cannot register its own policy. Roll back a breaking route to report-only, not the whole CSP.
CSP as a second layer, and how it gets bypassed
Content Security Policy is owned by the Security Headers Guide, which covers directives, nonces, hashes and rollout. For XSS the point is its place in the stack: OWASP says CSP "should not be your primary defense mechanism" and web.dev calls it a defence-in-depth technique that does not replace fixing XSS bugs. A content security policy bypass usually comes from the policy, not from a browser flaw:
- Host allowlists: web.dev says allowlist policies "can be bypassed by attackers", because an allowed CDN or analytics host often serves old libraries, JSONP endpoints or user content. Nonces or hashes with 'strict-dynamic' remove host trust.
- 'unsafe-inline' or 'unsafe-eval': either gives back most of what CSP added.
- Missing object-src 'none' and base-uri 'none': injected plugin content and base tags redirect script loading.
- Nonce misuse: nonces reused, cached with the page, or added by middleware to every script tag, including injected ones.
- Script gadgets: trusted framework code that turns attacker-controlled markup into script is still allowed by the policy; Trusted Types closes much of this gap.
Testing, scanning and detection
CWE-79 rates both automated static analysis and black-box testing as "Moderate" in effectiveness, so XSS scanning needs more than one technique. Match the method to the variant.
| Method | Finds well | Misses | Use it for |
|---|---|---|---|
| SAST with taint tracking | Request data reaching templates without encoding; raw-output helpers; dangerous DOM sinks | Framework auto-escaping it does not model; data stored by another service | Every pull request, blocking new high findings |
| DAST (reflection-based scanning) | Reflected XSS, some stored XSS on crawled pages | DOM-based XSS: WSTG says automated testing has "only very limited success"; stored XSS that fires in back-office pages | Staging builds and regression |
| Browser-instrumented or DOM-aware testing | Source-to-sink flows in client code, Trusted Types violations | Paths not exercised by the crawl | Single-page apps |
| Blind XSS testing with out-of-band callbacks | Stored input that executes later in support, moderation and admin tools | Nothing fires if internal tools are out of scope | Authorised tests that include back-office applications in scope |
| Manual review | Escape hatches, URL handling, sanitiser configuration and post-processing | Volume | Rich-text features, templating changes, new sinks |
What to monitor
- CSP and Trusted Types violation reports: a burst of script-src or trusted-types violations from one page or tenant is an injection attempt or a compromised third-party script. Send reports to the SIEM with page, directive, blocked URI and sample, and rate-limit the endpoint. See the SIEM Guide.
- Stored content signals: markup appearing in fields that never legitimately hold it, especially tickets, profile fields and file names viewed by staff.
- Session anomalies: admin API calls or token creation straight after a support agent opens a ticket.
- WAF events as a signal only: OWASP calls WAFs "unreliable" for XSS; their logs show probing, not coverage.
Metrics
- Open XSS findings by variant and median days to fix.
- Share of applications with zero unreviewed escape hatches.
- Share of applications, including back-office tools, enforcing a strict CSP and Trusted Types.
- CSP violations per 10,000 page views, falling during refactoring and flat after enforcement.
- Days between a sanitiser security release and its deployment.
Where identity fits
XSS is a session and token problem as much as a content problem. MITRE ATT&CK T1539 Steal Web Session Cookie describes adversaries stealing cookies by "injecting malicious JavaScript content into websites". HttpOnly cookies stop script reading the cookie, but injected script can still act inside the session, call APIs and read any bearer token kept in web storage. The Canvas Instructure breach is a textbook blind XSS: according to Instructure, code submitted in a support ticket ran when an agent opened it and yielded an authorisation token with elevated access, and a second XSS in the discussion feature was used later to mint a new token.
- Keep tokens out of reach of script: prefer HttpOnly, Secure, SameSite cookies or a backend-for-frontend over access tokens in localStorage. See the Token and Session Security Guide.
- Step up privileged actions: require re-authentication for token creation, role changes and exports, so script in a staff session cannot complete them silently.
- Isolate back-office tools: show customer content to agents in a sandboxed origin or as plain text.
Rollout and decision guidance
- Baseline: list applications, rich-text features and back-office tools that render customer data; tag findings by variant and CWE-79 child.
- Close server-side classes: enforce auto-escaping, lint escape hatches, remove global filters, fix error templates.
- Standardise sanitisation: one approved sanitiser per platform behind a shared helper, plus Markdown and SVG rules.
- Tackle DOM sinks and CSP: Trusted Types and a strict CSP, each report-only first, enforced in rings, with per-route rollback.
- Gate releases: SAST on merge, DAST and DOM-aware tests before release, ASVS V1 as acceptance criteria.
- Encode or sanitise: encode whenever data is meant to be text; sanitise only fields that must carry markup, and keep that list short.
- Server or client sanitiser: client-side at insertion for browser rendering; server-side where content feeds many consumers, then again in the browser.
- Trusted Types or CSP first: single-page apps with many sinks gain most from Trusted Types; server-rendered apps from a strict CSP.
- Small versus large organisations: small teams rely on framework defaults, an escape-hatch lint rule and an edge-set CSP; large ones publish approved encoder and sanitiser wrappers and track findings by variant across the portfolio.
Framework mapping
| Framework | Reference | What it asks for |
|---|---|---|
| OWASP ASVS 5.0 | V1.1.1, V1.1.2, V1.2.1, V1.2.2, V1.2.3, V1.3.1, V1.3.4, V1.3.5 | Canonicalise once, encode last, context encoding, safe URL schemes, sanitiser library, SVG and Markdown handling |
| OWASP Top 10:2025 | A05:2025 Injection | XSS (CWE-79) reported under Injection |
| MITRE CWE | CWE-79; children CWE-80, 81, 83, 84, 85, 86, 87 | Root cause and variant tagging for findings |
| NIST SP 800-53 Rev. 5 | SI-10, SI-15, SA-11 | Input validation, information output filtering, developer testing |
| CIS Controls v8.1 | 16.1, 16.12 | Secure application development process, code-level security checks |
| ISO/IEC 27001:2022 Annex A | A.8.28, A.8.29 | Secure coding, security testing in development and acceptance |
| MITRE ATT&CK v20 | T1539 Steal Web Session Cookie | Session theft that XSS enables |
Common failure patterns
- Back-office tools left out of scope: support consoles render customer input with less care than public pages, which is what the Canvas support-ticket XSS exploited.
- Sanitise, then transform: sanitised HTML passed through a Markdown step or link rewriter reopens the hole.
- Closing findings because the WAF blocks the test string: the flaw stays reachable through another encoding, a JSON API or a DOM source.
- An allowlist CSP counted as protection: a policy trusting a public CDN or keeping 'unsafe-inline' passes audits and stops little.
- Stale sanitiser versions: the library is pinned at the release before last year's mutation fix.
Practitioner checklist
- Use auto-escaping templates and lint every raw-output helper.
- Remove global encoding filters; encode at render time for the exact context.
- Serve accurate Content-Type and charset, nosniff, JSON as application/json, and uploads from a separate domain.
- Allow only http, https and mailto schemes in user-supplied links, checked on the parsed URL.
- Sanitise rich HTML, Markdown output and SVG with one approved, patched sanitiser, and never modify its output.
- Replace string DOM sinks with safe APIs and enforce Trusted Types after report-only.
- Enforce a strict CSP and send CSP and Trusted Types reports to the SIEM.
- Include support and admin tools in XSS testing, with blind XSS checks.
- Keep session tokens out of web storage and step up privileged actions.
- Track XSS findings by variant, time to fix and recurrence.
Standards and references
- MITRE: CWE-79 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- OWASP: A05:2025 Injection
- OWASP Cheat Sheet Series: Cross Site Scripting Prevention Cheat Sheet
- OWASP Cheat Sheet Series: DOM based XSS Prevention Cheat Sheet
- OWASP: Application Security Verification Standard 5.0, V1 Encoding and Sanitization
- CISA and FBI: Secure by Design Alert, Eliminating Cross-Site Scripting Vulnerabilities
- MDN: Trusted Types API
- Ruhr University Bochum: mXSS Attacks, Attacking well-secured Web-Applications by using innerHTML Mutations (ACM CCS 2013)
Related NHI Mgmt Group resources: Web Application Attacks Guide · Secure Coding Guide · Security Headers Guide · Token and Session Security Guide · Penetration Testing Guide