Skip to content
Blog

Secure Web Apps: CSRF, XSS, and CSP in 2026

CSRF, XSS, and CSP through the lens of the OWASP Top 10 2025: synchronizer tokens, SameSite, context-aware encoding, Trusted Types, and a nonce-based Content Security Policy that actually ships.

Published on August 11, 2026

AI Assistant

The OWASP Top 10 is the industry’s consensus list of the most critical web application risks, and the 2025 edition rearranged the furniture: cross-site request forgery (CWE-352) no longer owns a category and now maps under A01 Broken Access Control — the most prevalent category on the list. Cross-site scripting (CWE-79) moved under A05 Injection. Merging into categories is not a demotion; it is a warning that these attacks hide inside the broader access-control and injection problems every app ships.

Two of the three share a dependency 2026 attackers exploit relentlessly: XSS defeats every CSRF mitigation ever invented, because once script runs in your origin nothing else matters. A secure app is therefore not a checklist of headers — it is a layered posture where each layer assumes the one beneath will fail.

In this post, you will learn how the three attacks actually work, the defenses that hold in 2026 (synchronizer tokens, SameSite, signed double-submit cookies, context-aware output encoding, Trusted Types, and a strict nonce-based CSP), and how to ship them in a real Express app.

Key technologies: OWASP Top 10 2025, synchronizer token and double-submit patterns, SameSite cookies, Fetch Metadata headers, Trusted Types, Content Security Policy, and the Express 5 security stack.

Prerequisites

  • Node.js 20+ and npm, plus Express 5 familiarity.
  • Browser devtools for header inspection.
  • OWASP’s cheat sheets open in a tab (links in the references).

CSRF: forging state changes with the user’s own browser

CSRF works because browsers attach credentials automatically. Logged in to bank.com and visiting evil.com, a form or fetch from evil.com to bank.com travels with the session cookie. The attacker can’t read the response (same-origin policy) and doesn’t need to — a forged POST /transfer?to=evils&amount=99999 is a state change, not a data read.

The mitigations, in the order OWASP wants you to think about them:

  1. Use the framework’s built-in protection first — Django’s {% csrf_token %}, Rails’ protect_from_forgery, Spring Security’s filter. Don’t hand-roll where one exists.
  2. Synchronizer token pattern — an unpredictable token rendered into the form, validated on submit.
  3. Signed double-submit cookie for stateless APIs — HMAC-signed cookie value echoed back in a custom header.
  4. Defense in depthSameSite cookies, origin verification, custom headers for AJAX.

Synchronizer token in Express

csurf is retired, so here is the pattern with a session-backed token:

import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true,
                  cookie: { httpOnly: true, sameSite: 'lax', secure: true } }));

app.use((req, res, next) => {
  if (!req.session.csrfToken) req.session.csrfToken = crypto.randomBytes(32).toString('hex');
  res.locals.csrfToken = req.session.csrfToken;
  next();
});

app.post('/transfer', (req, res) => {
  if (req.body._csrf !== req.session.csrfToken) return res.status(403).send('CSRF failed');
  // ... do the transfer ...
});
<form method="post" action="/transfer">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <button type="submit">Send</button>
</form>

Signed double-submit for stateless APIs

For JSON APIs with no server-side session, OWASP recommends the signed double-submit — HMAC over a random value, delivered as a cookie and required as a header:

const SECRET = process.env.CSRF_FIXED_SECRET; // long random value, rotated regularly
const mint = (v) => crypto.createHmac('sha256', SECRET).update(v).digest('base64url');
// login: Set-Cookie csrf=<random>; csrf_sig=HMAC(csrf)  (HttpOnly, SameSite=Lax, Secure)
// request: X-CSRF-Token: <random>:<sig>
const verify = (token) => {
  const [value, sig] = token.split(':');
  return crypto.timingSafeEqual(Buffer.from(mint(value)), Buffer.from(sig));
};

SameSite: the quiet CSRF killer — and its limits

SameSite=Lax blocks CSRF on cross-site POSTs and is a sensible session-cookie default — but it is not a complete defense. SameSite=None, subdomain takeovers, and click-to-follow flows escape it, so treat it as depth, not the sole control. Modern browsers also send Fetch Metadata headers (Sec-Fetch-Site: cross-site), which you can reject for state-changing endpoints.

XSS: where output is allowed to become code

Stored (persisted, rendered to everyone), reflected (echoed from the URL/response), and DOM-based (untrusted input into a sink like innerHTML) XSS share one root cause: user-controlled data interpolated into a context the parser treats as code. The fix is context-aware output encoding:

ContextEncoded asExample
HTML element content&lt; &gt; &amp; &quot;<p><%= escape(text) %></p>
HTML attribute valueattribute-escaped + quotedvalue="<%= escapeAttr(x) %>"
JavaScript stringJS string escapingJSON.stringify(text)
URL attributeURL-safe, javascript:-blockedhref="<%= escapeUrl(u) %>"

Modern frameworks auto-escape — React JSX, Vue interpolation, EJS in escape mode, Go’s html/template. The mistakes persist where escaping was disabled or v-html/dangerouslySetInnerHTML/string concatenation wrote into DOM sinks. In 2026, DOM XSS gets a dedicated defense: Trusted Types, which force raw strings through a sanitizing policy:

<meta http-equiv="Content-Security-Policy" content="trusted-types default; require-trusted-types-for 'script'">
const policy = window.trustedTypes.createPolicy('default', {
  createHTML: (html) => DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }),
});
// el.innerHTML = userHtml            // BLOCKED by the browser
el.innerHTML = policy.createHTML(userHtml); // allowed, sanitized

Sanitization (a real library like DOMPurify, never regex) is for rich text you accept; encoding is for everything else. And HttpOnly cookies keep session cookies out of any script’s reach, so a single XSS can’t trivially exfiltrate them.

CSP: make the browser police your origin

Content Security Policy tells the browser what to run. The 2026-recommended shape is nonce-based with strict-dynamic — no unsafe-inline, no wildcard script hosts:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-<one-time-nonce>' 'strict-dynamic' https:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';
  upgrade-insecure-requests;
  report-uri /__csp_report__

A nonce is generated per request, placed on script tags, and rejected if reused:

import crypto from 'node:crypto';
import helmet from 'helmet';
app.use(helmet()); // sane header defaults incl. HSTS
app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  res.setHeader('Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic' https:; ` +
    `object-src 'none'; base-uri 'self'; frame-ancestors 'self'; ` +
    `upgrade-insecure-requests; report-uri /__csp_report__`);
  next();
});
<script nonce="<%= nonce %>">window.initApp();</script>

Roll out via Content-Security-Policy-Report-Only first: violations get reported to your report-uri handler (log document-uri and violated-directive) and nothing breaks; enforce only when the report stream is clean. Inline handlers (onclick="...", eval) break under strict CSP — moving them into addEventListener calls is the upgrade path, and it makes the codebase better rather than accruing unsafe-inline exceptions.

Putting It All Together

The runnable version — an Express 5 app with a session-backed CSRF token, signed double-submit middleware, DOMPurify + Trusted Types on the client, a nonce-based CSP with a reporting endpoint, and helmet’s header defaults — is in this gist: https://gist.github.com/redlinesoft/secure-express-app

Expected output against the running app:

$ curl -i http://localhost:3000/login
HTTP/1.1 200 OK
Set-Cookie: connect.sid=...; Path=/; HttpOnly; SameSite=Lax; Secure
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-Ab3...' 'strict-dynamic' https:; ...

$ curl -X POST -d "amount=100&_csrf=WRONG" http://localhost:3000/transfer
HTTP/1.1 403 Forbidden
CSRF failed

A forged cross-site POST is rejected (wrong token / SameSite), a script with a stale nonce is refused by the CSP, and a violation is reported to /__csp_report__ — three independent layers, each standing if the one beneath falls.

Conclusion & Next Steps

You can now reason about CSRF, XSS, and CSP as a stack: tokens and SameSite stop forged state changes, context-aware encoding and Trusted Types stop injection, and a strict nonce CSP catches whatever slips through. Next: enforce Sec-Fetch-Site checks on state-changing endpoints, wire a real report-to endpoint for CSP telemetry, run a dependency audit (A03 supply chain is the fastest-growing Top 10 category), and validate headers in CI against OWASP’s guidance.

References / Sources