Defence in Depth

Chapter 8
Defence in Depth
Custom headers, Origin/Referer checks, re-authentication, and combining layers

Each defence so far has gaps: tokens can be misimplemented, SameSite has the site-vs-origin and GET holes, double-submit has cookie injection. Defence in depth is the discipline of layering independent mechanisms so that an attacker must defeat all of them — and a single misconfiguration doesn't expose you. This chapter covers the supplementary defences and how to stack them.

Checking the Origin / Referer Header

On a cross-site request, the browser sets an Origin header (and usually Referer) identifying the page that initiated it — and the attacker's page cannot forge or remove these on a cross-site request (they're browser-controlled). So the server can simply check: did this state-changing request come from my own origin?

const ALLOWED = "https://app.example.com"; function checkOrigin(req, res, next) { const origin = req.get("Origin") || req.get("Referer"); if (!origin || !origin.startsWith(ALLOWED)) { return res.status(403).send("Cross-origin request rejected."); } next(); }
Origin checking is a useful layer, not a sole defence
Two caveats keep it from standing alone. (1) The Origin header is usually present on state-changing cross-site requests but historically wasn't on every request type, and Referer can be stripped by privacy settings, proxies, or Referrer-Policy — so a strict "reject if absent" can break legitimate clients, while "allow if absent" opens a hole. (2) It does nothing for the same-site sibling problem (Chapter 7) — a request from blog.example.com may carry an allowed-looking origin. Treat Origin/Referer checks as a strong corroborating layer alongside tokens + SameSite, not a replacement. Match against an explicit allowlist, exact-string, never a loose substring.

Requiring a Custom Request Header

From Chapter 2: a cross-site attacker cannot set a custom header on a simple request without triggering a CORS preflight they can't pass, and an HTML form can't set headers at all. So requiring a header like X-Requested-With: XMLHttpRequest on state-changing endpoints means a classic form/img forgery simply can't produce a valid request.

Why this works — and why it isn't enough alone
The mechanism is sound: the presence of any custom header forces same-origin/CORS-approved code, which a forged form can't be. It's the basis of the double-submit header (Chapter 6) and a common API defence (Chapter 9). But on its own it relies on the server strictly rejecting requests lacking the header and on the endpoint not also accepting simple-content-type bodies — and it gives no protection against a same-origin XSS. Use it as a layer, enforced strictly, paired with tokens.

Re-Authentication & Step-Up for Sensitive Actions

The strongest layer for the highest-value actions doesn't rely on request shape at all: require the user to prove intent directly — re-enter their password, confirm with a one-time code, or pass an MFA challenge — for operations like changing email/password, deleting an account, or making a payment.

This defeats CSRF by construction: even a perfectly forged request can't supply the current password or live MFA code, because the attacker doesn't know them (Chapter 3's "account takeover via change-email" is exactly what this stops). It's friction, so reserve it for genuinely sensitive operations — but for those, it's the most robust defence available.

The Layered Stack

A well-defended state-changing endpoint combines several independent checks — an attacker must beat every one:

Layer 1SameSite=Lax/Strict on the session cookie — strips the credential from most cross-site requests (Ch. 7)
Layer 2Anti-CSRF token (synchronizer or signed double-submit) validated on every state-changing request (Ch. 5–6)
Layer 3Origin/Referer check against an allowlist — corroborates the request came from your site
Layer 4Safe-method discipline — never change state on GET; require POST/PUT/DELETE
Layer 5Re-authentication / MFA for sensitive actions — proves live user intent (Ch. 3 takeover defence)
Layering is about independence, not quantity
Defence in depth only helps if the layers fail independently. Five variations of "trust the cookie" stacked together still all fall to one cookie problem. The stack above works because each layer rests on a different assumption: SameSite on the browser's cross-site rules, tokens on the attacker not reading your page, Origin on browser-set headers, safe-GET on HTTP semantics, re-auth on a secret the user holds. When you add a layer, ask: does this fail for a different reason than the others? If not, it's not adding depth. And remember the ceiling: none of this survives XSS (Chapter 4) — same-origin script defeats the lot, so XSS prevention sits beneath the whole stack.

What to Actually Ship

For most apps the pragmatic, strong baseline is: SameSite=Lax + anti-CSRF tokens (via a maintained library) + safe-GET discipline, with Origin checks as an easy extra layer and re-authentication on the handful of truly sensitive endpoints. You don't need every layer everywhere — you need enough independent ones that no single failure is catastrophic, weighted toward your highest-value actions.

Hands-On Exercises

Exercise 1

Implement an Origin/Referer check middleware in Express that allows only your own origin on state-changing requests. Then explain two reasons it must not be your only CSRF defence (header absence handling, and the same-site sibling gap).

📄 View solution
Exercise 2

Explain why requiring re-authentication (re-entering the password) on a "change email" endpoint defeats CSRF even if every other defence were somehow bypassed. Connect this back to the account-takeover chain from Chapter 3.

📄 View solution
Exercise 3

A team proposes "defence in depth": session cookie, a second auth cookie, and a CSRF cookie all checked server-side. Explain why this is not real defence in depth, then design a genuinely independent 3-layer stack and state the distinct assumption each layer relies on.

📄 View solution

Chapter 8 Quick Reference

  • Defence in depth — layer independent mechanisms so no single failure exposes you
  • Origin/Referer check — reject state-changing requests not from your allowlisted origin; browser-set, attacker can't forge cross-site
  • Origin caveats: header may be absent (proxies/Referrer-Policy) and won't catch same-site siblings — a layer, not sole defence
  • Custom required header — forms can't set headers; cross-site fetch triggers a preflight — blocks classic forgery (enforce strictly)
  • Re-authentication / MFA for sensitive actions — defeats CSRF by construction (attacker lacks the password/OTP); reserve for high-value ops
  • Strong stack: SameSite + token + Origin check + safe-GET + re-auth on sensitive endpoints
  • Layers must fail for different reasons — five cookie-trusting checks aren't depth; and nothing survives XSS
  • Ship baseline: SameSite=Lax + tokens (library) + safe-GET, plus Origin checks and re-auth where it matters
  • Next chapter: CSRF in modern apps — SPAs, JSON APIs, bearer vs cookie auth, framework built-ins