Quick answer: XSS is prevented through output encoding by context — HTML-encode content inserted into HTML body, JavaScript-encode data inside script blocks, URL-encode query parameters, and CSS-encode values inside style attributes. Use DOMPurify to sanitize HTML input and a Content Security Policy (CSP) with nonces as a second layer of defense. Never use innerHTML with untrusted data — use textContent instead.
Last week, a client called me in a panic. Their e-commerce platform had been compromised — attackers injected malicious JavaScript into their product review section, and every customer who visited those pages had their session cookies silently exfiltrated to an external server. By the time we traced the attack, over 3,000 user sessions had been stolen. The fix? A few lines of output encoding that should have been there from day one. That's XSS in the real world — not a theoretical vulnerability, but a quiet, patient attack that sits in your application waiting for victims.
I've spent 15+ years finding XSS vulnerabilities in production applications, from Fortune 500 banking portals to small SaaS startups. This cheatsheet is everything I wish developers had followed before I had to show up and write the incident report.
This guide follows the OWASP XSS Prevention Cheat Sheet framework and extends it with real-world examples from penetration testing engagements.
What XSS Actually Is (And Why Developers Keep Getting It Wrong)
Cross-Site Scripting happens when an application includes untrusted data in a web page without proper validation or encoding. The browser can't distinguish between your legitimate JavaScript and the attacker's injected payload — it just executes both. There are three types you need to care about:
Reflected XSS — The malicious script comes from the current HTTP request. Classic example: a search parameter that echoes back "You searched for: [input]" without sanitization.
Stored XSS — The payload is stored in the database and served to every user who loads that page. This is what hit my client above. Far more dangerous because it's persistent and self-propagating.
DOM-based XSS — The vulnerability exists entirely in client-side code. The payload never reaches the server; it's processed by the browser's DOM engine. These are the hardest to detect with traditional scanners.
Most developers I audit understand reflected XSS conceptually but completely miss DOM-based vulnerabilities because they're scanning server-side code while the problem lives in their React components or jQuery event handlers.
Input Validation vs Output Encoding — Know the Difference
This is where I see the most confusion. These are two separate defenses and you need both.
Input validation happens on the way in. Reject or sanitize data that doesn't match expected formats. If a field expects a US phone number, it should only accept digits and hyphens. If it expects an age, it should only accept integers in a reasonable range.
// Input validation example — whitelist approach
function validateUsername(input) {
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/;
if (!usernameRegex.test(input)) {
throw new Error('Invalid username format');
}
return input;
}
Output encoding happens on the way out. Convert dangerous characters into their safe HTML entity equivalents before rendering them in the browser. This is your last line of defense.
// Output encoding — never trust, always encode
function htmlEncode(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
}
Don't write your own encoding library. Use established ones: OWASP Java Encoder for Java, Microsoft AntiXSS library for .NET, or DOMPurify for client-side JavaScript.
Context-Specific Encoding — The Part Most Guides Skip
Here's what separates a developer who understands XSS from one who just read a tutorial: encoding rules change depending on where in the HTML document you're inserting data.
HTML Body Context
<!-- Safe: HTML entity encoding -->
<p>Welcome back, <script>alert(1)</script></p>
HTML Attribute Context
<!-- DANGEROUS — even with quotes, some attributes execute JS -->
<input value="USER_INPUT_HERE">
<!-- Safe approach — always quote attributes AND encode -->
<input value="<?= htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') ?>">
JavaScript Context
// NEVER put untrusted data directly in JavaScript
var username = "<?= $userInput ?>"; // Dangerous
// Safe: JSON encode server-side, parse client-side
var userData = JSON.parse('<?= json_encode($userInput) ?>');
URL Context
<!-- Validate that URL starts with http/https — never trust blind -->
<?php
$url = $_GET['redirect'];
if (!preg_match('/^https?:\/\//i', $url)) {
$url = '/'; // Default safe redirect
}
?>
<a href="<?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?>">Click here</a>
CSS Context
Never put user-controlled data into CSS. Ever. I've bypassed sanitizers through CSS injection more times than I can count. If you absolutely must, validate it's a specific expected value from a whitelist.
Content Security Policy — Your Safety Net
CSP is a browser security mechanism that restricts what resources can load on your page. Even if an attacker injects a script, a properly configured CSP can prevent it from executing. I've implemented CSP headers on dozens of client applications and it's one of the highest ROI security improvements you can make.
Add this header to your HTTP responses:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';
The nonce approach is critical. Generate a cryptographically random nonce for each page load and include it in both the CSP header and your script tags:
<?php
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: script-src 'nonce-{$nonce}' 'strict-dynamic'");
?>
<script nonce="<?= $nonce ?>">
// Your legitimate scripts here
</script>
Any injected script without the correct nonce gets blocked. Test your CSP at Google's CSP Evaluator before going live — weak configurations like unsafe-inline or wildcard sources defeat the entire purpose.
Framework-Specific Protections
Modern frameworks give you XSS protection by default if you use them correctly.
React
React escapes all values in JSX by default. The dangerous part is when developers bypass this protection:
// DANGEROUS — never do this with untrusted input
<div dangerouslySetInnerHTML={{ __html: userContent }} />
// If you must render HTML (e.g., rich text), sanitize first
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />
Angular
Angular sanitizes values by default in template bindings. The bypass to watch for:
// DANGEROUS
this.trustedHtml = this.sanitizer.bypassSecurityTrustHtml(userInput);
// Use only for content you fully control, never for user input
Vue.js
<!-- DANGEROUS — v-html renders raw HTML -->
<div v-html="userContent"></div>
<!-- Safe — v-text or {{ }} automatically escapes -->
<div>{{ userContent }}</div>
5 Mistakes I Find in Every Penetration Test
After years of security assessments, these are the issues I find most consistently:
Mistake 1: Sanitizing on input, forgetting to encode on output. Teams add a regex strip on the way in, then render the stored data raw later. A bypassed filter on input means stored malicious content renders with full effect on output.
Mistake 2: Trusting innerHTML assignments.
// Wrong
document.getElementById('username').innerHTML = userData.name;
// Right
document.getElementById('username').textContent = userData.name;
Mistake 3: Misconfigured CSP with unsafe-inline. I've audited organizations that spent weeks implementing CSP then included 'unsafe-inline' because a third-party widget required it. This completely negates your XSS protection. The solution is to contact the vendor for a nonce-compatible version or self-host the script.
Mistake 4: Forgetting about JSON endpoints. Always set Content-Type: application/json — never text/html — on API responses. An endpoint returning HTML-like data with the wrong content type can be leveraged for injection.
Mistake 5: Not testing for DOM XSS. Automated DAST scanners miss most DOM-based XSS because they don't execute JavaScript or observe browser behavior. I manually test URL fragments (#), document.location, document.referrer, and postMessage handlers — all common sources of DOM XSS that scanners miss.
Tools I Actually Use for XSS Testing
- Burp Suite Pro — Intercept and modify requests, use the active scanner, and manually craft payloads through the Repeater module
-
XSStrike — Python-based XSS scanner with fuzzing capabilities:
python3 xsstrike.py -u "https://target.com/search?q=test" - OWASP ZAP — Excellent for automated crawling and baseline scanning, especially in CI/CD pipelines
- DOMinator — Specifically for tracing DOM-based XSS sources and sinks
-
Browser DevTools — The Sources panel with breakpoints on dangerous sinks like
innerHTML,document.write, andevalhas helped me find more vulnerabilities than any automated tool
For payload generation, I reference the PortSwigger XSS cheat sheet — the most comprehensive filter bypass reference available.
XSS in 2026: What's Changed
AI-assisted payload generation. Attackers are feeding WAF rulesets into LLMs and generating tailored bypass payloads in seconds. Payloads that previously required an expert researcher to craft manually are now one API call away. Context-specific encoding and CSP nonces matter more than ever — they're logic-based defenses that can't be bypassed by payload mutation alone.
Client-side rendering complexity. The dominance of React, Vue, and Angular SPAs means the DOM is now constructed almost entirely in JavaScript. Traditional server-side output encoding is necessary but no longer sufficient. I'm spending more time auditing postMessage handlers, dynamic import() calls, and client-side routing logic where untrusted URL fragments feed directly into DOM sinks.
Trusted Types enforcement. The Trusted Types browser API — now supported in Chrome, Edge, and shipping in Firefox — lets you enforce that DOM sink assignments only accept validated objects, not raw strings:
// Enforce Trusted Types in your CSP
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types myPolicy
// Create a policy — only way to assign to innerHTML
const policy = trustedTypes.createPolicy('myPolicy', {
createHTML: (input) => DOMPurify.sanitize(input),
});
// Now innerHTML only accepts trusted type objects — raw strings throw
element.innerHTML = policy.createHTML(userContent); // Safe
element.innerHTML = userContent; // Throws TypeError
This doesn't replace your existing defenses but adds a hard enforcement layer that breaks entire classes of DOM XSS at the browser level. Start with report-only mode to identify violations before enforcing.
FAQ
Q: Is XSS really that serious? Can't browsers just block it?
Yes, it's serious — and no, browsers alone can't block it. XSS leads to account takeover, credential theft, keylogging, cryptomining, and full session hijacking. I've used XSS to pivot to internal network access in corporate pentests.
Q: We use a WAF — aren't we protected?
Partially. WAFs catch known signatures and basic payloads, but I bypass WAFs routinely using encoding tricks, polyglot payloads, and context manipulation. Treat it as a speed bump, not a wall.
Q: Should I use DOMPurify or write my own sanitizer?
Always use a vetted library. Writing your own sanitizer is one of the most common ways I find bypasses. DOMPurify has been audited and battle-tested. Use it.
Q: We encode HTML entities everywhere — why did our pen test still flag XSS?
Because HTML entity encoding only protects in HTML body context. If you're inserting data into a JavaScript block, a CSS property, a URL parameter, or an unquoted HTML attribute, different encoding rules apply. I find this exact issue in almost every application I audit.
Originally published at xuro.net — web security & WordPress consulting.
Top comments (0)