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:
- Session cookies — the primary impact in most cases: full session hijacking
- localStorage and sessionStorage — auth tokens, user data, cached responses
- DOM content — anything visible on the page, including forms and displayed data
- The browser's origin context — making requests as the victim to the application's API
- Credential harvesting — injecting fake login forms over the real page
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:
- URL parameters reflected in page content or error messages
- Form fields — search, comments, profile fields, messages
- HTTP headers —
Referer,User-Agentif logged/displayed anywhere - File names if displayed after upload
- API responses rendered by the frontend
- Error messages that echo back request data
For DOM XSS, the approach is different — read the JavaScript. Sources to look for:
document.URL,location.href,location.hash,location.searchdocument.referrerwindow.namepostMessagehandlerslocalStorage/sessionStorage— if data from external sources is stored and later rendered
Sinks to look for:
innerHTML,outerHTML,insertAdjacentHTMLdocument.write,document.writelneval(),setTimeout(string),setInterval(string),new Function(string)element.src,element.href— for javascript: URL injection- jQuery's
$(),.html(),.append()when passed user-controlled strings
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>
<xss1234test>
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:
<div>xss1234test</div> → try <script>alert(1)</script> or <img src=x onerror=alert(1)>
<input value="xss1234test"> → try " onmouseover="alert(1) or " autofocus onfocus="alert(1)
var x = 'xss1234test'; → try '-alert(1)-' or ';alert(1)//
<a href="xss1234test"> → try javascript:alert(1)
<!-- 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:
- The page where the submission appears to the original user
- Admin/moderation views
- Email notifications that include user content
- Export features (CSV/PDF generation from user-supplied data)
- API responses that other frontend components consume
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:
dangerouslySetInnerHTML(React) — explicitly bypasses auto-escaping. Used legitimately for rich text rendering, but developers sometimes use it as a convenience shortcut and forget to sanitize the input.v-html(Vue) — same pattern, different framework[innerHTML]binding (Angular) — Angular does sanitize this, butbypassSecurityTrustHtml()removes that protection- Template literal injection in web components using native DOM manipulation
- Server-side rendering (SSR) — if user content is injected into the server-rendered HTML before hydration, framework auto-escaping hasn't engaged yet
- Third-party libraries that accept HTML strings: rich text editors, markdown renderers, chart tooltip configurations
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:
- HTML body context: HTML entity encoding —
<→<,>→>,&→& - HTML attribute context: HTML attribute encoding — additionally encode
"and' - JavaScript string context: JavaScript encoding — escape backslashes, quotes, and control characters
- URL context: URL encoding for user-supplied values in URLs
- CSS context: CSS encoding if user data appears in style attributes
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:
- DOMPurify (client-side, JavaScript) — mature, widely deployed, actively maintained
- Bleach (Python) — allowlist-based HTML sanitizer
- OWASP Java HTML Sanitizer (Java)
- HtmlSanitizer (.NET)
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
- Map all input fields that render output back to users
- Test reflected XSS in URL parameters, search fields, error messages
- Test stored XSS in all persistent fields: comments, profiles, messages, filenames
- Check display locations for stored data: admin panels, reports, notifications, exports
- Review JavaScript for DOM sources and sinks (grep for
innerHTML,document.write,eval) - Test DOM XSS via URL fragments and parameters read by client-side code
- Search codebase for framework escape bypasses:
dangerouslySetInnerHTML,v-html,bypassSecurityTrustHtml - Check CSP headers — are they present? Do they block inline scripts?
- Verify
HttpOnlyflag on session cookies - Test third-party library inputs: rich text editors, chart configs, markdown renderers
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