Skip to content

CSRF protection

Severity: mediumApplies to: FastAPI 0.100+Applies to: Starlette 1.xFacts last verified 2026-08-16 against FastAPI 0.141.1 · Starlette 1.6.0 · Pydantic 2.13.4

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.

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.

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.

the fix

For a cookie-authenticated application, the double-submit pattern as a dependency:

import secrets
from 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.

verify it workedRun this in: http response
Terminal window
# a state-changing request with the cookie but no matching header
curl -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:

Terminal window
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:

if you see this
TypeError: `@validator(..., each_item=True)` cannot be applied to fields with a schema of json-or-python

Raised 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.14

It 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.

settings on this page
fastapi-jwt-authoption

Not 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
Read in: PyPI JSON API — fastapi-jwt-auth 0.5.0 requires_dist; import reproduced on FastAPI 0.141.1 / Pydantic 2.13.4

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.

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.

before you ship this

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"):
return

Roll 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.

  • Session cookies — where same_site is set, and the rest of the cookie flags
  • CORS configuration — the other cross-origin control, and not a substitute for this one