CSRF protection
There is no CSRF implementation in FastAPI or Starlette. The complete shipped
middleware list is authentication, base, cors, errors, exceptions, gzip,
httpsredirect, sessions, trustedhost and wsgi; the only occurrences of the word
“CSRF” in FastAPI’s source are docstrings about the Swagger UI OAuth2 redirect.
That absence is defensible, and it is the reason this page is a decision rather than a fix. CSRF is an attack on ambient credentials — things the browser attaches by itself. Whether you are exposed depends on which kind you use.
Decide which case you are in
Section titled “Decide which case you are in”You authenticate with a token in a header. Authorization: Bearer ..., read from
memory or storage by your own JavaScript and set deliberately on each request. A
cross-site form post cannot set that header, and a cross-site fetch that tries is
subject to CORS preflight. You do not need CSRF tokens. Adding them is cost with no
benefit, and it is the most common unnecessary control in this ecosystem.
You authenticate with a cookie. SessionMiddleware, or any scheme where the
browser attaches credentials automatically. The browser will attach that cookie to a
request another site caused. You need CSRF protection, and FastAPI gives you none.
You do both — a cookie for the web app and bearer tokens for an SDK, on the same routes. Then the cookie path is exposed and the header path is not, and the mitigation has to cover the cookie path without breaking the other.
The mistake worth avoiding is deciding from the framework rather than from the credential. “FastAPI doesn’t have CSRF middleware” is not evidence that you are safe.
SameSite is most of the answer
Section titled “SameSite is most of the answer”SessionMiddleware sets same_site='lax' by default. Lax means the cookie is not
attached to cross-site POST, PUT, PATCH or DELETE — which is the shape almost
every classic CSRF takes.
That is real protection and it is on unless you turned it off. Setting
same_site='none' for a cross-origin front end removes it entirely, and that is the
change that reintroduces the exposure — so if you have a cookie-authenticated API on a
different origin from its front end, you are in the case that needs tokens.
What Lax does not cover: top-level GET navigations, which is only a problem if a
GET on your API changes state. If any does, that is the actual bug — fix the method
before reaching for tokens.
For a cookie-authenticated application, the double-submit pattern as a dependency:
import secretsfrom fastapi import Depends, HTTPException, Request, Response
SAFE = {"GET", "HEAD", "OPTIONS", "TRACE"}
def issue_csrf(response: Response) -> str: token = secrets.token_urlsafe(32) response.set_cookie( "csrftoken", token, secure=True, httponly=False, samesite="lax", path="/", ) return token
async def require_csrf(request: Request) -> None: if request.method in SAFE: return cookie = request.cookies.get("csrftoken") header = request.headers.get("x-csrf-token") if not cookie or not header or not secrets.compare_digest(cookie, header): raise HTTPException(status_code=403, detail="CSRF token missing or invalid")
app = FastAPI(dependencies=[Depends(require_csrf)])Three details carry the weight. httponly=False is deliberate and is the one that
looks wrong — the front end has to read this cookie to echo it back, so it is the one
cookie that must be script-readable. secrets.compare_digest rather than == avoids
leaking the comparison through timing. And putting the dependency on FastAPI(...)
applies it to every route, so a new endpoint is covered without anyone remembering.
The security this provides comes from the same-origin policy: another site can cause a request that carries your cookies, but it cannot read them, so it cannot produce the matching header.
# a state-changing request with the cookie but no matching headercurl -s -o /dev/null -w '%{http_code}\n' -X POST \ https://api.example.com/account/email \ -H 'Cookie: session=<a real session>; csrftoken=abc' \ -H 'Content-Type: application/json' -d '{"email":"attacker@evil.example"}'403 is correct. 200 means the endpoint accepts a request that a cross-site page
could have produced.
Then confirm the cookie’s SameSite, since that is the layer underneath:
curl -sI https://app.example.com/login | grep -i 'set-cookie'Third-party packages, and which of them still run
Section titled “Third-party packages, and which of them still run”Three come up when you search this question. None is maintained by the FastAPI project. Each was installed into a clean Python 3.14 environment on 2026-08-16 alongside FastAPI 0.141.1 · Starlette 1.6.0 · Pydantic 2.13.4 and imported:
| Package | Latest release | Repo last push | Open issues + PRs | Imports here |
|---|---|---|---|---|
fastapi-csrf-protect 1.0.7 |
2025-09-16 | 2026-08-11 | 0 | yes |
starlette-csrf 3.0.0 |
2023-06-27 | 2025-03-15 | 6 | yes |
fastapi-jwt-auth 0.5.0 |
2020-11-06 | 2024-04-12 | 61 | no |
The first two both work. fastapi-csrf-protect is the more active of them — its last
release is older than its last commit, so read the repository rather than the release
date if you are judging it. starlette-csrf is quieter but imports and runs; it is
Starlette-level middleware, so it applies to every route rather than being attached as a
dependency.
The one that ranks is the one that no longer runs
Section titled “The one that ranks is the one that no longer runs”fastapi-jwt-auth is not a CSRF package. It is a JWT library whose cookie mode carries
double-submit options, and its CSRF documentation page is what search tends to surface
for this question. Two things are worth knowing before following it.
It declares fastapi>=0.61.0 with no upper bound, so pip resolves and installs it
against current FastAPI without a dependency error. The failure arrives at import,
because the package is written against Pydantic 1:
TypeError: `@validator(..., each_item=True)` cannot be applied to fields with a schema of json-or-pythonRaised at `import fastapi_jwt_auth` on Pydantic 2, from the package's own `__init__.py`. The install step gives no error at all — the failure arrives when the app starts.
Read in: reproduced — fastapi-jwt-auth 0.5.0 on FastAPI 0.141.1 / Starlette 1.6.0 / Pydantic 2.13.4, Python 3.14It also declares PyJWT>=1.7.1,<2.0.0. That upper bound excludes 2.4.0, which is
where CVE-2022-29217 — algorithm confusion through non-blocklisted public key formats —
was fixed. The pin and the fixed version cannot both be satisfied, and a fresh install
pulls PyJWT 1.7.1, published in 2019.
fastapi-jwt-authoptionNot a CSRF package — a JWT library whose cookie mode carries double-submit options, and whose CSRF documentation page is what search surfaces for this question. It declares fastapi>=0.61.0 with no upper bound, so pip installs it alongside current FastAPI without complaint, and it then fails at import because it is written against Pydantic 1. It also pins PyJWT>=1.7.1,<2.0.0, and that upper bound excludes 2.4.0, where CVE-2022-29217 was fixed.
- default
0.5.0, published 2020-11-06- set in
requirements.txt
The general point is worth more than the specific package: a clean pip install is not
evidence that a dependency runs. An unbounded requirement will resolve against
anything, and the incompatibility surfaces when your application starts.
Or write the thirty lines
Section titled “Or write the thirty lines”The dependency shown above is about thirty lines, carries no maintenance risk, and is easier to reason about than a package you have not read. For most applications that is the better trade — and it is the same conclusion the table points at, since two of the three options here are quieter than the code they replace.
Turning on a global CSRF dependency rejects every existing non-browser client that does not send the header — mobile apps, server-to-server callers, webhooks, your own integration tests, and anything using a bearer token that never needed this.
Webhooks are the sharp edge: an inbound webhook from a payment provider cannot send your CSRF token, and it will start returning 403 the moment this ships. Exempt those routes explicitly, and protect them the way webhooks should be protected — by verifying the sender’s signature.
The dependency above also covers routes authenticated by bearer token, where it is pure
cost. If you serve both credential types, skip the check when the request carries an
Authorization header, since that path was never exposed:
if request.headers.get("authorization"): returnRoll out in report-only first if you can: log the mismatch instead of raising, watch for a week, and see which of your own clients appear before you start rejecting them.
Related
Section titled “Related”- Session cookies — where
same_siteis set, and the rest of the cookie flags - CORS configuration — the other cross-origin control, and not a substitute for this one