NodeJSCross-Site Scripting (XSS)

Cross-Site Scripting (XSS)

XSS is the injection of attacker-controlled scripts into a page that runs in other users' browsers. A successful XSS payload can steal session cookies, impersonate the victim, redirect to phishing pages, or silently exfiltrate data. It is consistently in the OWASP Top 10 and disproportionately common because every place you render user-supplied content is a potential vector. This page covers the three XSS types, the defences at each layer (output encoding, CSP, HttpOnly cookies), and the server-side role in preventing stored XSS.

The three types

Type

How it works

Example

Stored (persistent)

Malicious input saved to DB, rendered to all viewers

Forum post with <script> tag

Reflected

Payload in the URL, server echoes it in the response

/search?q=<script>alert(1)</script>

DOM-based

Client-side JS reads URL/storage and writes to DOM unsafely

element.innerHTML = location.hash

Stored XSS is the most dangerous — it runs on every page view by every user
A stored XSS payload in a database field gets served to every user who views that page. A single stored payload could affect thousands of users — stealing all their session cookies, defacing the site for everyone, or silently logging keystrokes. Server-side rendering apps must sanitize HTML before storage and before output. Client-side frameworks (React, Vue) are safer because they escape by default — but only when you use their templating; bypasses like `dangerouslySetInnerHTML` and `v-html` re-introduce the risk.
Defence 1 — output encoding

JS
// In server-rendered templates, use the engine's auto-escaping output tag:

// EJS:
<%= user.bio %>       // ✅ HTML-escaped: '<script>' becomes '&lt;script&gt;'
<%- user.bio %>       // ❌ raw — only for already-sanitized HTML

// Pug:
= user.bio            // ✅ auto-escaped
!= user.bio           // ❌ raw

// Handlebars:
{{ user.bio }}        // ✅ auto-escaped
{{{ user.bio }}}      // ❌ raw

// React / JSX — safe by default:
<p>{user.bio}</p>     // ✅ auto-escaped
<p dangerouslySetInnerHTML={{ __html: user.bio }} />  // ❌ raw
Every template engine has a raw/unsafe output mode — only use it for pre-sanitized HTML
The raw output modes (`<%-`, `!=`, `{{{}}}`, `dangerouslySetInnerHTML`) exist for rendering pre-sanitized rich HTML — e.g. a blog post body that went through DOMPurify before storage. They should never be used with unsanitized user input. Review your templates for these patterns in code review; a single misuse can reintroduce XSS into an otherwise safe codebase.
Defence 2 — Content Security Policy

JS
// A strict CSP prevents inline scripts from running even if injected:
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc:  ["'self'"],   // no 'unsafe-inline' → injected <script> tags do nothing
      // Use nonces for legitimate inline scripts:
      // scriptSrc: ["'self'", `'nonce-${generateNonce()}'`],
    },
  },
}))
CSP is defence-in-depth — it limits the damage when output encoding fails
A strict `Content-Security-Policy` that forbids `'unsafe-inline'` means even a successfully injected `<script>` tag won't execute — the browser refuses to run it. CSP is not a substitute for output encoding (an attacker can still do DOM manipulation through event attributes that bypass some CSPs), but it's a powerful second layer. A policy that allows `'unsafe-inline'` provides almost no XSS protection.
Defence 3 — HttpOnly cookies

JS
// An HttpOnly cookie cannot be read by document.cookie — blocks cookie theft via XSS:
res.cookie('session', token, {
  httpOnly: true,    // ← JavaScript cannot read this cookie
  secure: true,
  sameSite: 'lax',
})
HttpOnly limits XSS blast radius — even if a script runs, it can't steal the session cookie
Even with output encoding and CSP, defence-in-depth means assuming an XSS payload might execute somewhere. `HttpOnly` cookies mean that even if it does, the attacker's script can't read `document.cookie` to steal the session — the most common XSS goal. This doesn't stop all XSS attacks (a script can still make authenticated API calls in the victim's context), but it blocks the most common and highest-impact credential theft.
Stored XSS — sanitize before storage

JS
import DOMPurify from 'isomorphic-dompurify'

// User submits a rich-text blog post:
app.post('/posts', authenticate, asyncHandler(async (req, res) => {
  const { title, body } = req.body

  // Sanitize the HTML body before storing:
  const safeBody = DOMPurify.sanitize(body, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'blockquote', 'code'],
    ALLOWED_ATTR: ['href'],
  })

  await db.posts.create({ title, body: safeBody, authorId: req.user.id })
  res.status(201).json({ ok: true })
}))
Sanitize rich HTML on input AND encode on output — two layers for the same threat
Some teams sanitize HTML on input; others encode on output. Both layers are better than one. Sanitize on input (with DOMPurify) to prevent storing a payload; encode on output (template auto-escaping) as a final safety net. If you only sanitize on input and then switch to a raw output tag, old stored data protects you but new bugs don't. If you only encode on output, rendering a safe plain-text field that happens to contain a URL can still trigger issues in some contexts.
DOM-based XSS — the client-side version

JS
// ❌ Reads from location.hash or URLSearchParams and writes to DOM without encoding:
document.getElementById('message').innerHTML = location.hash.slice(1)
// Attacker crafts: https://yourapp.com/page#<img src=x onerror=stealCookies()>

// ✅ Use textContent for plain text:
document.getElementById('message').textContent = location.hash.slice(1)

// ✅ Or DOMPurify if HTML is genuinely needed:
document.getElementById('message').innerHTML = DOMPurify.sanitize(location.hash.slice(1))
XSS prevention checklist
  • Auto-escaping output in all templates — never use raw modes with untrusted input.

  • Sanitize rich HTML before storage with DOMPurify or sanitize-html.

  • CSP without unsafe-inline as a second layer.

  • HttpOnly session cookies to limit cookie theft if a payload runs.

  • Avoid innerHTML, document.write in client-side code; use textContent.

  • Review code for dangerouslySetInnerHTML, v-html, <%- %> in PRs.

Next
Protect state-changing requests from forged origins: [CSRF Protection](/nodejs/csrf-protection).