CSRF Protection¶
A browser attaches cookies to a request automatically — even one triggered
by a page on a completely different site. That's exactly what session-cookie
auth (Authentication) relies on, and exactly what
makes it forgeable without protection: a malicious page can trigger a
POST to your app and the victim's session cookie rides along, no user
interaction beyond "visited a page" required. CsrfMiddleware closes that
gap, and it's on by default the moment you register AuthServiceProvider.
(This covers POST/PUT/PATCH/DELETE over HTTP specifically. A
WebSocket handshake is also a plain HTTP request that carries cookies
automatically -- see WebSockets: Origin protection
for the equivalent guard there.)
The double-submit-cookie pattern¶
A random token is set as a readable (non-HttpOnly) cookie. Any unsafe
request (POST, PUT, PATCH, DELETE) must also send that same value
back in a header (X-CSRF-Token by default). A cross-site attacker's page
can trigger the cookie to be sent automatically, but can't read its
value — the same-origin policy blocks that — so it can't also set the
matching header. A forged request is missing the header, or has the wrong
value, and gets rejected with a 403.
curl -i http://localhost:8000/ # any response sets the csrf_token cookie
curl -X POST http://localhost:8000/posts \
-b cookies.txt \
-H "X-CSRF-Token: <value of the csrf_token cookie>" \
-d '{"title": "..."}'
From a browser/JS client¶
Read the cookie directly — no server round trip needed to fetch a token separately:
function csrfToken() {
return document.cookie
.split("; ")
.find((row) => row.startsWith("csrf_token="))
?.split("=")[1];
}
fetch("/posts", {
method: "POST",
headers: { "X-CSRF-Token": csrfToken(), "Content-Type": "application/json" },
body: JSON.stringify({ title: "..." }),
credentials: "same-origin",
});
The first request from a new client must be a safe one
The first unsafe request after a client has never talked to your
app before will fail — there's no cookie to read yet. Make any safe
(GET) request first (loading the page itself counts), the same way
Django's and Laravel's equivalents work.
What's exempt¶
- Safe methods (
GET/HEAD/OPTIONS/TRACE) — they never change state, so there's nothing to forge. - Requests carrying an
Authorizationheader — a bearer token (API Authentication) isn't attached to cross-site requests automatically the way a cookie is, so it was never vulnerable to this in the first place./api/tokenand everyrequire_api_auth-guarded route work exactly as before. - Requests with no session cookie at all, if you pass
protect_if_cookie_presentyourself — CSRF only matters when there's an existing cookie-authenticated session to forge an action within, so a cookie-less request (a token-issuing endpoint, a brand new client's very first request) has nothing ambient for a forged request to ride on.AuthServiceProviderdeliberately does not set this, though: exempting a cookie-less request would exempt login/register themselves, opening login-CSRF (a cross-site page forcing a victim's browser to register/log in as an attacker's account, attributing whatever the victim does next to it) -- soPOST /register//loginneed the CSRF header too, the same as every other unsafe request. - A request your own
exemptpredicate returnsTruefor — for an endpoint that's itself a legitimate, necessarily cross-sitePOSTwith its own independent proof of authenticity. Nothing is exempted this way by default; see SAML SSO for the one built-in use.
Setup¶
Comes bundled with AuthServiceProvider — nothing extra to register:
# main.py
from zeython import Application, AuthServiceProvider
app = Application()
app.register(AuthServiceProvider(app, user_model=User))
Configurable via .env:
CSRF_ENABLED— defaulttrue.CSRF_COOKIE_NAME— defaultcsrf_token.CSRF_HEADER_NAME— defaultX-CSRF-Token.form_field_name(constructor argument, not yet an.envsetting) — default_token; see Server-rendered forms.
Think twice before setting CSRF_ENABLED=false
Turning it off is rarely the right call, since it's exactly what makes cookie-based auth safe to use from a browser — a pure mobile/native client that never runs in a browser context is the one legitimate reason to.
Using CsrfMiddleware standalone, without AuthServiceProvider (a
generic double-submit-cookie check with no auth-awareness, protecting
every unsafe request unconditionally):
Server-rendered forms¶
A plain HTML <form> submission — unlike fetch — can't set a custom
header, so CsrfMiddleware also accepts the token as a _token field in
the form body itself (configurable via form_field_name), in addition to
the header. Embed it with csrf_token(request):
from zeython.csrf import csrf_token
@app.get("/posts/new")
async def new_post_form(request):
return render(request, "posts/new.html", {"csrf_token": csrf_token(request)})
<form method="post" action="/posts">
<input type="hidden" name="_token" value="{{ csrf_token }}" />
...
</form>
Using Blade, the @csrf directive renders exactly that hidden
field:
A plain <form> also only ever submits GET or POST — pair this with
@method('PUT') (renders a hidden _method field) and the opt-in
MethodOverrideMiddleware (zeython.routing) to drive a
PUT/PATCH/DELETE route from one.