Web Security Cheatsheet

CSRF

Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

What CSRF is

Cross-Site Request Forgery makes an authenticated user's browser send a state-changing request the user did not intend. The attacker never reads the response and never steals the cookie, they only need the browser to attach it automatically.

Three conditions must all hold:

1. The action is state changing (create, update, delete, transfer, permission). 2. Authentication is ambient, meaning the browser attaches it without the page asking, which is cookies, HTTP Basic, and client TLS certificates. 3. Nothing in the request is unpredictable to a third party.

// Vulnerable: session cookie is the only proof of intent
app.post('/account/email', (req, res) => {
  db.updateEmail(req.session.userId, req.body.email)  // no token, no check
})

An Authorization: Bearer header read from sessionStorage is not ambient, so a pure token API without cookies has no CSRF surface. Cookie-based sessions do.

SameSite cookies

SameSite tells the browser when to attach a cookie to a cross-site request. It is the strongest single mitigation and it is now the default in major browsers, but it is not sufficient on its own.

ValueSent on cross-site requestsNotes
StrictNeverSafest. An inbound link from another site arrives logged out.
LaxOnly top-level GET navigationsBrowser default when the attribute is absent
NoneAlwaysRequires Secure, needed for genuine third-party contexts
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
Set-Cookie: __Host-session=...; HttpOnly; Secure; SameSite=Strict; Path=/

The __Host- prefix is worth knowing. A browser only accepts it when the cookie is Secure, has Path=/, and carries no Domain attribute, which pins it to the exact host and blocks a subdomain from overwriting it. __Secure- requires only the Secure flag.

Why SameSite alone is not enough

SameSite is a real control, but treating it as complete is a documented mistake.

GapDetail
Same-site is not same-originSameSite uses the registrable domain, so evil.example.com counts as same-site with app.example.com. A subdomain takeover or an XSS on any sibling subdomain bypasses it.
Lax still allows GETAny state-changing GET handler remains reachable. Never mutate state on GET.
Legacy and non-browser clientsOlder browsers and embedded webviews may ignore the attribute entirely.
SameSite=None is required sometimesGenuine cross-site embedding forces None, which removes the protection.
Method override middlewareA framework that honors _method=DELETE or X-HTTP-Method-Override turns a Lax-permitted POST into anything.
Defense in depthOne attribute, one browser bug, no second layer.

Treat SameSite as the outer layer and a token as the control. OWASP's guidance is explicit that SameSite is a supplement, not a replacement.

Token patterns

Synchronizer token (stateful). The server generates a random token, stores it against the session, and requires it back on every state-changing request. This is the strongest pattern and what most frameworks implement.

# Server side, per session
token = secrets.token_urlsafe(32)
session['csrf'] = token

# On the request, compare in constant time
sent = request.form.get('csrf') or request.headers.get('X-CSRF-Token')
if not sent or not hmac.compare_digest(sent, session.get('csrf', '')):
    abort(403)

Double-submit cookie (stateless). The token goes in both a cookie and a header or field, and the server checks they match. It needs no server state, but it is weaker, because a sibling subdomain can set a cookie on the parent domain. Sign the token with a server key (HMAC bound to the session id) to close that gap.

// Signed double submit: the cookie value is HMAC(sessionId, serverKey)
const expected = crypto.createHmac('sha256', KEY)
  .update(req.session.id).digest('base64url')
if (!crypto.timingSafeEqual(Buffer.from(req.get('X-CSRF-Token') ?? ''),
                            Buffer.from(expected))) return res.sendStatus(403)

Token rules that matter: generate from a CSPRNG, rotate on login and on privilege change, compare with a constant-time function, and never put the token in a URL, where it leaks through Referer, logs, and browser history.

Custom header and origin checks

A custom request header is a solid check for JSON APIs. The browser will not let a cross-origin page set an arbitrary header without a successful CORS preflight, so requiring one means the request came from your own JS.

// Require a header that only your own fetch code sends
if (req.get('X-Requested-With') !== 'XMLHttpRequest') return res.sendStatus(403)

This only holds if your CORS policy does not reflect arbitrary origins. Origin: * with credentials, or an origin allowlist built by string startsWith, undoes it.

Origin verification is a good secondary check. Prefer Origin, fall back to Referer, and fail closed when both are absent on a state-changing request.

ALLOWED = {"https://app.example.com"}
origin = request.headers.get("Origin") or request.headers.get("Referer")
if request.method not in ("GET", "HEAD", "OPTIONS"):
    if not origin or urlparse(origin).geturl().rstrip("/") not in ALLOWED:
        abort(403)

Compare the parsed origin against an exact set. Substring and endsWith checks are the classic bypass, since https://app.example.com.evil.tld ends with nothing you intended but passes a naive startsWith.

Framework support

Use the built-in rather than a hand-rolled check. Most of these are on by default and the vulnerability is usually an explicit exemption.

FrameworkMechanismEscape hatch to watch for
DjangoCsrfViewMiddleware, {% csrf_token %}@csrf_exempt
Railsprotect_from_forgery with: :exceptionskip_before_action :verify_authenticity_token
LaravelVerifyCsrfToken, @csrfThe $except array
Spring SecurityCSRF on by default.csrf().disable()
ASP.NET CoreAutoValidateAntiforgeryToken[IgnoreAntiforgeryToken]
Expresscsrf-csrf or csrf-sync (csurf is deprecated)Middleware mounted after the route
Next.jsServer Actions include an origin checkA route handler you wrote by hand

Recognizing it in review

grep -rn "csrf_exempt\|verify_authenticity_token\|csrf().disable\|IgnoreAntiforgery" .
grep -rn "SameSite=None\|sameSite: *'none'" .
grep -rniE "app\.(get)\(.*(delete|update|create|remove)" src/

Checklist:

- No state change happens on GET, HEAD, or OPTIONS. - Session cookies carry HttpOnly, Secure, and SameSite=Lax or Strict. - Every state-changing endpoint validates a token, a custom header, or Origin. - Token comparison is constant time, and tokens rotate on login. - Absent Origin and Referer on a mutation is a rejection, not a pass. - CORS does not reflect arbitrary origins with Allow-Credentials: true, and the allowlist is an exact-match set. - Framework CSRF protection is enabled with no unexplained exemptions. - Sensitive actions such as changing an email, a password, or a payout destination require the current password or a re-authentication step, which defeats CSRF and session hijacking at once.

Further reading: the OWASP CSRF Prevention Cheat Sheet and MDN Set-Cookie SameSite.