Back to Security
Security
medium
mid

How do you prevent XSS and CSRF attacks in a web application?

XSS (cross-site scripting): attacker injects script that runs in user's browser. Defend by escaping output (frameworks do it for you), strict Content-Security-Policy, HttpOnly cookies (script can't read), avoid innerHTML/dangerouslySetInnerHTML with user input, sanitize HTML with DOMPurify when you must. CSRF (cross-site request forgery): attacker tricks user's browser into making authenticated requests. Defend with SameSite=Lax cookies, CSRF tokens (double-submit cookie), origin/sec-fetch-site validation. They're separate threats with separate defenses.

10 min read·~5 min to think through

XSS and CSRF are two of the most common web vulnerabilities. They attack different surfaces and need different defenses.

XSS — Cross-Site Scripting

Attacker injects script that runs in the user's browser with the user's privileges. Three flavors:

  • Stored: malicious payload saved on the server (e.g., a comment with <script>steal()</script>); served to other users.
  • Reflected: payload in URL/form parameters reflected back into the page (e.g., search results page echoing the query).
  • DOM-based: payload manipulated client-side into a sink (innerHTML, eval, document.write).

What attackers do: steal cookies/tokens, deface UI, phish credentials, perform actions as the user.

Defenses

1. Escape output by default.

React, Vue, Svelte all escape {value} interpolations. Strings can't become script unless you opt in explicitly:

jsx
<div>{userInput}</div>   ← safe, escaped
<div dangerouslySetInnerHTML={{ __html: userInput }} />   ← dangerous

Server-side: use a templating engine that auto-escapes (most do). Never concatenate strings into HTML manually.

2. Sanitize when you must accept HTML.

Rich-text editors produce HTML. Use DOMPurify:

js
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'li'] });
container.innerHTML = clean;

DOMPurify is battle-tested and handles weird edge cases (SVG <use> references, mutation XSS).

3. Content Security Policy (CSP).

A whitelist of what scripts/styles/etc. the browser will execute:

ts
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123' https://cdn.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;

Nonces (nonce-abc123) let your inline scripts run while blocking attacker-injected inline scripts. Adopt CSP strict-dynamic for modern apps.

4. HttpOnly cookies for sessions.

ts
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax

HttpOnly means JS can't read the cookie. Even if XSS succeeds, the session token isn't stealable via document.cookie. Combined with short token lifetimes, this limits blast radius.

5. Avoid dangerous APIs.

  • eval, new Function — never with user input.
  • element.innerHTML = userInput — escape or sanitize.
  • document.write — avoid entirely.
  • URL schemes: validate href/src aren't javascript: URIs.

6. Trusted Types (Chromium).

ts
Content-Security-Policy: require-trusted-types-for 'script'

Forces dangerous DOM sinks (innerHTML, document.write) to require a TrustedHTML object — eliminates entire classes of DOM XSS at runtime.

CSRF — Cross-Site Request Forgery

Attacker tricks the user's browser into making an authenticated request to your site:

html
<!-- on evil.com -->
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="evil">
  <input name="amount" value="1000">
</form>
<script>document.forms[0].submit()</script>

The browser sends the user's bank.com cookies along with the request, so it looks legitimate.

Defenses

1. SameSite cookies.

ts
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax
  • SameSite=Lax (browser default since 2020): cookie not sent on cross-site sub-resource requests (image, iframe, fetch); only sent on top-level GET navigations.
  • SameSite=Strict: even stricter; not sent on top-level cross-site navs either.

This kills the simple CSRF vector. Mostly sufficient on its own for new apps.

2. CSRF tokens (double-submit cookie).

Server sets a random token in a cookie. Client reads it (via JS or a meta tag) and sends it back in a header on every state-changing request. Server verifies the header matches the cookie value.

ts
Cookie: csrf-token=abc123
Header: X-CSRF-Token: abc123

Attacker's evil.com can't read the cookie (different origin) → can't put the right token in the header → request rejected.

3. Origin / Sec-Fetch-Site header validation.

Modern browsers send:

ts
Sec-Fetch-Site: same-origin | same-site | cross-site | none

Reject state-changing requests where Sec-Fetch-Site is cross-site (and the endpoint isn't supposed to be CORS-callable).

4. Don't use GET for mutations.

GET /transfer?to=evil&amount=1000 is the easiest CSRF target — embedding as <img src> triggers it. Mutations belong on POST/PUT/DELETE.

How they combine

XSS and CSRF are independent threats with independent defenses, but they interact:

  • XSS subverts CSRF defenses (the attacker's script can read the CSRF token from the same origin).
  • HttpOnly cookies survive XSS for token theft, but a sufficiently-privileged XSS can perform actions via fetch (effectively chaining XSS → CSRF).
  • A strong CSP limits what an XSS can do — even if the attacker injects script, it may not be able to talk to attacker.com.

Defense in depth:

  • Auto-escape output + sanitize HTML → prevents most XSS.
  • CSP → limits XSS impact.
  • HttpOnly + Secure + SameSite cookies → prevents token theft + most CSRF.
  • CSRF token / origin check → catches CSRF that SameSite doesn't.
  • Trusted Types → blocks DOM XSS sinks.

Pitfalls

  • Trusting "Same Origin Policy will save me" → SOP prevents reading responses, not making requests.
  • SameSite=None without Secure → silently rejected.
  • CSRF token in a hidden form field but no validation → security theater.
  • Sanitizing on display but not on storage → next view might skip sanitize.
  • Storing tokens in localStorage → XSS-readable, no HttpOnly equivalent.
  • CSP with 'unsafe-inline' and 'unsafe-eval' → defeats most of its purpose.
  • Echoing user input in error messages without escaping.

Mental model

XSS = stop attacker code from running in user's browser. CSRF = stop attacker site from making user's browser do things. Use frameworks that auto-escape; cookies with HttpOnly + SameSite; CSP to limit damage; CSRF tokens or Origin checks for state-changing requests.

Follow-up questions

  • What is CSP and how does it stop XSS?
  • Why is SameSite=Lax usually enough for CSRF?
  • What's Trusted Types and what does it solve?
  • How does XSS subvert CSRF defenses?

Common mistakes

  • innerHTML / dangerouslySetInnerHTML with user input.
  • Storing tokens in localStorage instead of HttpOnly cookies.
  • GET endpoints that mutate state — trivially CSRF-able.
  • SameSite=None without Secure.
  • CSP with unsafe-inline and unsafe-eval — defeats the point.
  • CSRF token without server-side validation — security theater.

Performance considerations

  • Security headers (CSP, HSTS, SameSite) have negligible perf cost. CSRF token roundtrip is one extra request at session start. DOMPurify sanitization is fast (<1ms typical) for normal-size inputs. The cost of bad security is dollars and reputation, not milliseconds.

Edge cases

  • Subdomains: app.example.com and api.example.com are same-site but different-origin — design auth accordingly.
  • Cross-origin embeds (iframes, OAuth): SameSite=None + Secure required.
  • Old browsers without SameSite default — use explicit SameSite=Lax/Strict.
  • SPA route param echoed in title — DOM XSS opportunity.
  • Markdown renderers can produce XSS if not sanitized — strip raw HTML.

Real-world examples

  • OWASP Top 10 lists XSS and CSRF in different positions year to year; both consistently top 10.
  • Stripe, Google, GitHub all set strict CSP + HttpOnly + SameSite.
  • MyBB and other forum software historically had XSS regressions despite framework escaping — sanitization on rich-text is the long-tail risk.

Senior engineer discussion

Seniors articulate XSS and CSRF as separate threats with overlapping defenses. They reach for HttpOnly + SameSite + CSP first, sanitize rich-text with DOMPurify, validate Origin/Sec-Fetch-Site headers, and treat 'we use React so we're safe' as a half-truth (dangerouslySetInnerHTML, third-party HTML, eval still bite). They also adopt Trusted Types where possible.

Related questions