Cross-Site Scripting (XSS) Testing Guide: Find and Fix XSS Vulnerabilities

Cross-site scripting (XSS) is among the most common web application vulnerabilities. It consistently appears in bug bounty programs, penetration test reports, and breach post-mortems. Despite being well-understood for decades, it keeps showing up in modern applications — including ones built with React, Vue, and Angular.

That persistence has a reason: XSS isn't one vulnerability. It's a class of vulnerabilities with three distinct types, each requiring different testing approaches. Automated scanners catch one type reasonably well and struggle with the other two. This guide explains how to test for all three, what the detection limits are, and what remediation actually requires.

What XSS Actually Does

XSS lets an attacker inject JavaScript (or other executable content) into a web page that other users load. Because the script runs in the victim's browser in the context of the trusted domain, it has access to:

A stored XSS in a high-traffic admin panel is a critical vulnerability. A reflected XSS that requires a carefully crafted URL to be clicked is medium-severity. The impact scales with persistence (stored vs. reflected), how many users see the injection point, and what's accessible in that user's session.

The Three Types of XSS

Reflected XSS

The injected script is in the HTTP request and reflected back in the response. Classic example: a search page that displays "You searched for: [query]" where query comes from the URL. If the query isn't escaped, an attacker crafts a URL with a script tag, gets a victim to click it, and the browser executes the script.

Reflected XSS requires the victim to click an attacker-controlled link. That limits exploitability — phishing campaigns, shortened URLs, and email links are common delivery mechanisms. Severity is typically medium to high depending on what's accessible in the target session.

Stored XSS

The injected script is stored in the application (database, file, etc.) and executed whenever users load the affected page. A comment field, profile bio, or message thread that doesn't sanitize input is a classic stored XSS vector.

Stored XSS is typically high to critical severity. It executes automatically for every user who views the affected page — no social engineering required. If the injection is in an admin interface, attacker-controlled JavaScript runs in the admin's browser, which is often as good as full application compromise.

DOM-based XSS

The injection happens entirely in the browser — server-side code is never involved. Client-side JavaScript reads from a source (URL fragment, document.referrer, localStorage, postMessage) and writes it to a sink (innerHTML, document.write, eval) without sanitization.

DOM XSS is invisible to the server and to most traditional scanners. A request never touches the server with the payload. This makes it harder to detect and is the reason most automated tools significantly undercount XSS in modern JavaScript-heavy applications.

Finding Injection Points

For reflected and stored XSS, map every point where user input is rendered:

For DOM XSS, the approach is different — read the JavaScript. Sources to look for:

Sinks to look for:

Manual Testing Techniques

Initial probe

Inject a distinctive canary string first — not a script tag — to understand where and how your input appears in the response:

xss1234test
<xss1234test>
&lt;xss1234test&gt;

View the page source (not the rendered DOM — use View Source or Burp's response tab) and find where the string appears. The context determines the payload:

HTML body context

<div>xss1234test</div> → try <script>alert(1)</script> or <img src=x onerror=alert(1)>

HTML attribute context

<input value="xss1234test"> → try " onmouseover="alert(1) or " autofocus onfocus="alert(1)

JavaScript string context

var x = 'xss1234test'; → try '-alert(1)-' or ';alert(1)//

URL context

<a href="xss1234test"> → try javascript:alert(1)

HTML comment context

<!-- xss1234test --> → try --><script>alert(1)</script>

Payload crafting

The right payload depends on context. Start simple and escalate when filters are present:

<!-- Basic HTML injection -->
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>

<!-- Attribute injection -->
" onmouseover="alert(1)
" autofocus onfocus="alert(1)
' onmouseover='alert(1)

<!-- JavaScript context -->
'-alert(1)-'
\';alert(1)//

<!-- Filter bypass attempts -->
<ScRiPt>alert(1)</ScRiPt>
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg/onload=alert(1)>
<<script>alert(1)//<</script>

The goal in testing is to demonstrate JavaScript execution, not to produce a visible alert box. In modern browsers, alert(1) works fine for proof-of-concept, but production exploits use fetch() to exfiltrate data or document.cookie to steal sessions.

Testing stored XSS

Submit payloads in every stored field, then check both the storage endpoint and every other page that renders the stored data. The trick with stored XSS is that the display location may be different from the submission location — an admin panel displaying user-submitted content is a common scenario.

When testing comment/message fields, check the following rendering locations:

Testing DOM XSS

DOM XSS testing requires browser interaction, not just HTTP request manipulation. The payload must be in a source that client-side JavaScript reads:

<!-- URL fragment (not sent to server) -->
https://example.com/search#<img src=x onerror=alert(1)>

<!-- URL parameter (if rendered by JS, not server) -->
https://example.com/?q=<img src=x onerror=alert(1)>

<!-- If the page uses hash for routing -->
https://example.com/#/user/<img src=x onerror=alert(1)>

Open the browser's JavaScript console, watch for DOM manipulation, and trace where URL/hash values end up. Browser Developer Tools → Sources → watch innerHTML assignments. The DOM XSS Scanner extension for Chrome can automate some of this tracing.

What Automated Scanning Covers

XSS Type Automated Detection Notes
Reflected XSS Good Reliable when scanner can crawl/authenticate
Stored XSS Moderate Requires multi-request correlation; misses if display is in a different section
DOM XSS Limited Requires JavaScript execution in a headless browser; many tools skip this
XSS in SPA routing Poor Crawling single-page apps is hard; hash-based routing often missed
XSS via API responses Limited Requires understanding how frontend renders API data

Ironimo uses a headless browser (Chromium) for scanning, which significantly improves DOM XSS detection compared to scanners that only analyze HTTP responses. But DOM XSS in complex SPA routing, custom postMessage handlers, and second-order rendering paths still require manual review.

Modern Frameworks: Why XSS Still Happens

React, Vue, and Angular all auto-escape output by default. A React component rendering {user.name} will HTML-encode special characters. So why does XSS still show up in modern applications?

Several common escape hatches:

Search your codebase for these patterns. Each instance is a candidate for XSS review.

Content Security Policy: Defense in Depth

Content Security Policy (CSP) is the browser-level defense against XSS. A well-configured CSP tells the browser which script sources are trusted — injected inline scripts from XSS won't execute if CSP disallows inline scripts.

Effective CSP for XSS mitigation:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://trusted-cdn.example.com;
  object-src 'none';
  base-uri 'self'

The critical rule is avoiding 'unsafe-inline' and 'unsafe-eval' in script-src. These defeat the purpose of CSP. Unfortunately, they're commonly present because adding CSP to an existing application often requires refactoring inline scripts — which teams skip.

Test CSP effectiveness with Google's CSP Evaluator (csp-evaluator.withgoogle.com). Common CSP bypasses include wildcard sources (*.example.com), hosting attackable content on trusted domains, and JSONP endpoints on allowlisted domains.

CSP is defense in depth, not a fix. Remediate the XSS; add CSP as an additional layer.

Remediation: What Actually Works

Context-sensitive output encoding (primary fix)

XSS exists because data is inserted into an HTML document without encoding characters that have special meaning in that context. The fix is encoding output appropriately for where it appears:

Most server-side frameworks provide encoding functions. Use the framework's built-in templating (which escapes by default) rather than manual string concatenation.

Input sanitization for rich text

When you need to accept HTML (rich text editors, markdown with HTML output), output encoding doesn't work — you need to allow some HTML. In this case, use an allowlist-based HTML sanitization library:

Roll-your-own sanitization with regex or blocklists is a common mistake. It will fail. Use a maintained library with an allowlist approach.

HttpOnly and Secure cookie flags

While not an XSS fix, HttpOnly on session cookies prevents JavaScript from reading them — limiting the impact of XSS from session hijacking to other attacks. Every session cookie should have HttpOnly set. This is a quick win that reduces the severity of any XSS that exists.

Testing Checklist

Ironimo tests for reflected, stored, and DOM-based XSS across your application — running the same tools professional pentesters use, with a headless browser for JavaScript-heavy SPA coverage.

Find XSS before attackers do. Schedule automated scans on demand or on a recurring cadence, with results that include reproduction steps and remediation guidance.

Start free scan
← Back to blog