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.
Why SameSite alone is not enough
SameSite is a real control, but treating it as complete is a documented mistake.
| Gap | Detail |
|---|---|
| Same-site is not same-origin | SameSite 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 GET | Any state-changing GET handler remains reachable. Never mutate state on GET. |
| Legacy and non-browser clients | Older browsers and embedded webviews may ignore the attribute entirely. |
SameSite=None is required sometimes | Genuine cross-site embedding forces None, which removes the protection. |
| Method override middleware | A framework that honors _method=DELETE or X-HTTP-Method-Override turns a Lax-permitted POST into anything. |
| Defense in depth | One attribute, one browser bug, no second layer. |
Treat
SameSiteas the outer layer and a token as the control. OWASP's guidance is explicit thatSameSiteis 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.
| Framework | Mechanism | Escape hatch to watch for |
|---|---|---|
| Django | CsrfViewMiddleware, {% csrf_token %} | @csrf_exempt |
| Rails | protect_from_forgery with: :exception | skip_before_action :verify_authenticity_token |
| Laravel | VerifyCsrfToken, @csrf | The $except array |
| Spring Security | CSRF on by default | .csrf().disable() |
| ASP.NET Core | AutoValidateAntiforgeryToken | [IgnoreAntiforgeryToken] |
| Express | csrf-csrf or csrf-sync (csurf is deprecated) | Middleware mounted after the route |
| Next.js | Server Actions include an origin check | A 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.