For every state changing request that relies on cookies, check that the framework's CSRF protection covers it, or that a token or custom header is verified. Confirm GET requests never change state, and treat SameSite cookies as defense in depth. XSS defeats all of these, so it still needs its own fix.
When CSRF applies
The OWASP CSRF Prevention Cheat Sheet explains that cookie authenticated sites need protection because browsers attach cookies automatically to cross-site requests. It advises using the framework's built-in protection first, and adding tokens to all state changing requests if none exists.
If a request is authenticated by a header that scripts add, such as a bearer token from local code, the browser does not attach it on its own, which changes the risk. Know which model the endpoint uses before commenting.
The options a reviewer will see
| Approach | Review notes |
|---|---|
| Synchronizer token | Server creates a secret per session and checks it on each state changing request. Token goes in a form or header, not in a cookie |
| Signed double submit cookie | Stateless option. The signed version bound to the session is recommended. The naive version is discouraged |
| Custom request header | Works for AJAX and APIs because cross-origin requests with custom headers need a CORS preflight. Only trusted origins may be allowed |
| SameSite Lax or Strict | Helps, but not a full defense |
Steps for a review
- List the new or changed routes that create, update or delete data.
- For each, find what proves the request came from your own page. If the answer is only the cookie, flag it.
- Check that any exemption list in the middleware is justified. A growing skip list is a smell.
- Make sure no state change happens on GET, HEAD or OPTIONS. The cheat sheet is clear that these must be safe, and SameSite Lax does not stop a top level GET navigation.
- Check CORS settings on the same routes. A wildcard subdomain policy weakens the custom header approach.
# red flag: a GET that changes state
@app.get("/account/delete")
def delete_account(): ...
# better: POST with a CSRF token checked by middleware
@app.post("/account/delete")
def delete_account(): ...
Why SameSite is not the whole answer
The cheat sheet notes that Lax still allows cookies on top level GET navigations, that cookie scope follows the registrable domain so sibling subdomains matter, and that browser coverage is not universal. MDN adds that a cookie with no SameSite attribute is treated as Lax by default. Use these as a second layer.
XSS undoes the rest
Injected script runs as your origin. It can read tokens and send valid requests. The cheat sheet says CSRF defenses do not replace XSS prevention. In practice, a clean CSRF review in an app with an open XSS hole is a false comfort.
Try it yourself
Run the web scan in the demo and see how a dynamic scan reports missing protections on a form based page.
Click through it at your own pace, or open it full screen.
Where Safeguard fits
Safeguard scans dependencies and SBOMs, which covers the web framework and its security middleware versions. The free scan is at gold.safeguard.sh/scan.
Quick check
Does SameSite replace a CSRF token?
No. OWASP treats it as defense in depth. Lax still allows cookies on top level GET navigations.
Why must GET be safe?
Because SameSite Lax and similar defenses do not stop state changes made through GET.
Sources
Last reviewed: 19 October 2026.