Quick Answer: A static XSS audit reads React, TypeScript, and JavaScript source to find where untrusted data reaches an HTML, URL, script, or CSS sink without proper encoding. The open-source xss-audit skill runs 14 review layers and writes its findings to xss-audit-report.md. Its matches are candidates, not confirmed bugs, so each one still needs a source-to-sink check.

Searching a React codebase for dangerouslySetInnerHTML returns hits within seconds. Most are harmless. A few are the bug that pays a bounty. The hard part of a static XSS audit isn't finding the sink. It's proving the path from attacker-controlled input to that sink and checking what stands in between. The xss-audit repository packages that process as a 14-layer checklist.

Why Grepping for Sinks Misses Real Bugs

Here's the lazy advice most guides repeat: search for innerHTML, fix every hit. That approach buries you in noise and still misses the dangerous cases.

React escapes text nodes by default, so {userBio} inside JSX is safe. The risk sits in places React doesn't protect. Consider <a href={user.website}>. React escapes the attribute string, but it doesn't block javascript: URLs. Since React 16.9 it logs a warning for them, yet it still renders the link. When a user saves javascript:fetch('/api/me') as a website, the next visitor who clicks that link runs the attacker's code.

That's why the xss-audit skill treats URL validation as its own layer rather than a side note. Your fix is an explicit protocol allowlist, such as https: and http: only, applied before the value reaches the attribute. Blocklists fail here because browsers accept whitespace, control characters, and mixed case inside scheme names.

The 14 Layers, Grouped by Where Data Goes

Rather than treating the layers as a flat checklist, group them by the destination of the data:

  • HTML sinks: innerHTML, outerHTML, insertAdjacentHTML, document.write, DOMParser, setHTMLUnsafe, and React's dangerouslySetInnerHTML.
  • Attributes and URLs: dynamic on* handlers, href, src, srcdoc, action, formaction, style, window.open, and router navigation.
  • Serialized data: SSR bootstrap state, hydration payloads, JSON-LD, and </script> breakouts.
  • Code and styles: eval, new Function, string timers, Blob URLs, cssText, insertRule, and CSS-in-JS interpolation.
  • Markup dialects: SVG, MathML, and XML namespace handling, plus Markdown pipelines.
  • Framework trust hooks: Vue v-html, Angular bypass APIs, Svelte {@html}, Lit unsafeHTML, and template compilation in AngularJS, Handlebars, and similar engines.
  • Browser-side controls and state: Trusted Types, CSP, postMessage origin checks, prototype pollution gadgets, and untrusted storage.

Grouping this way makes triage faster. A hit in the "serialized data" group needs different evidence than one in the "framework trust hooks" group.

The JSON-in-Script Breakout Most Teams Miss

This one surprises people. Suppose your server renders state like this:

<script>window.__STATE__ = ${JSON.stringify(state)};</script>

If state.comment contains </script><img src=x onerror=alert(1)>, JSON.stringify produces valid JSON that still closes the script tag. The browser's HTML parser ends the script block before the JavaScript engine sees the data. The fix is to escape <, >, &, U+2028, and U+2029 into Unicode escape sequences after serialization. Libraries such as serialize-javascript do this for you, and OWASP's XSS Prevention Cheat Sheet documents the same rule.

Notice the cause and effect: the data is correct JSON, the JSON is correct JavaScript, and the page is still vulnerable, because the failure happens at the HTML layer.

Sanitizer Pipelines and Where mXSS Hides

Here's the counter-intuitive part. A string that passed through DOMPurify is not safe forever. Its safety depends on the exact string the sanitizer returned and on the context where it's parsed next.

Mutation XSS (mXSS) exploits the gap between how a sanitizer parses markup and how the browser reparses it after serialization. Common ways teams reopen this gap:

  1. Running a regex or string concatenation after sanitization, for example adding target="_blank" to every link.
  2. Passing sanitized HTML through a second parser, such as a Markdown renderer that accepts raw HTML.
  3. Widening ALLOWED_TAGS to include svg, math, or style without testing namespace-confused payloads.

DOMPurify has shipped several mXSS fixes over the years, so the resolved version in your lockfile matters as much as your configuration. Run npm ls dompurify and confirm you're on a current release. Then make sanitization the last transform before insertion.

That said, sanitization isn't the only defense. Output context matters: text, attribute, URL, and script contexts each need different encoding, and a sanitized HTML fragment can still be unsafe inside a href.

Comparing Audit Approaches

No single method covers everything. Here's how the common options compare:

ApproachStrengthBlind spotBest use
Pattern search (grep, Semgrep rules)Fast inventory of every sinkNo proof of data flow; high false-positive rateFirst pass over a large repository
Taint analysis (CodeQL, Semgrep taint mode)Traces source to sink across functionsNeeds modeled sanitizers; misses framework-specific behaviorCI gating for known source types
Dynamic scanning (OWASP ZAP, Burp Suite)Confirms execution in a running appCovers only reachable pages and test inputsStaging validation of suspected paths
Structured LLM-guided review (xss-audit)Applies 14 layers with context and lists unread filesOutput needs human verification; depends on what the model readsReviewing an unfamiliar codebase systematically

The best workflow combines these. Use pattern search for coverage, a structured review for context, and dynamic testing to confirm.

Trusted Types and CSP: Defense in Depth, Not Proof

Trusted Types, enforced through the require-trusted-types-for 'script' CSP directive, makes browsers reject plain strings written to sinks like innerHTML. Writes must go through a named policy. That's a strong control, but it has a trap: a default policy that returns its input unchanged gives you the appearance of protection with none of the substance.

Start in report-only mode using Content-Security-Policy-Report-Only, review the violation reports, and then enforce. Treat Trusted Types as a backstop. It doesn't prove your code is safe, and support is strongest in Chromium-based browsers, so check your actual user-agent mix before relying on it.

Triage: Turning Matches Into Findings

Once the scan produces candidates, work through each one in this order:

  1. Trace the source. Is the value from a URL, API response, postMessage, or storage?
  2. Identify the sink context: HTML, attribute, URL, script, or CSS.
  3. Check the sanitizer and its effective configuration, not just its presence.
  4. Check for transforms after sanitization or a second parse.
  5. Write a reproduction and label each step as executed or theoretical.

That last step is the one most reports skip. The xss-audit report separates theoretical steps from executed ones, and that separation is what lets a reviewer trust the output.

Limitations to Plan For

Static analysis misses runtime-only behavior, build-time transforms, excluded directories, and framework-specific timing. A clean report isn't a clean application. Reports can also contain internal paths, domains, and code snippets, so sanitize them before sharing outside your team.

The author reports ranking in Microsoft's MSRC Security Researcher Leaderboard, including 2025 Q3 at #4 and 2026 Q1 at #6, and credits the skill to a year of bug bounty work. That's a self-reported background, so judge the tool on whether its layers match your code, not on the credentials.

Frequently Asked Questions

What is a static XSS audit?

A static XSS audit inspects source code without running it to find places where untrusted data reaches a dangerous output sink, such as innerHTML, an href attribute, or a script block. It produces candidates that need data-flow verification. Its value is coverage: it can scan an entire repository for patterns a manual reviewer might skip.

How to find XSS in React code?

Start by searching for dangerouslySetInnerHTML, dynamic href and src attributes, and any JSON serialized into script tags. For each hit, trace the source of the value and confirm whether it's sanitized or validated for the specific sink. Then test the path in a running app to confirm execution.

Why does React still allow XSS?

React escapes text content by default, but it does not block dangerous URL schemes in attributes like href, and it doesn't sanitize HTML you explicitly pass through dangerouslySetInnerHTML. Any value that reaches those paths needs validation or sanitization from your code.

Can Trusted Types replace sanitization?

No. Trusted Types restricts which strings can reach dangerous sinks, but a policy that approves unsanitized input still lets attacker content through. Use it as defense in depth alongside a sanitizer like DOMPurify and output-context encoding.

Is a grep match enough to report an XSS?

No. A match shows that a sink exists, not that attacker input reaches it. Confirm the source, the context, and the effective sanitization before reporting, and label any unexecuted reproduction steps as theoretical.

Run the skill on one module this week, manually verify the first three findings, and note how many were real. That single exercise will tell you more about your codebase than a full-repo scan with no follow-up. Pass this to anyone wrestling with noisy static XSS audit results, because the fix is usually better triage, not more alerts.