Skip to content

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

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 Authorization header — 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/token and every require_api_auth-guarded route work exactly as before.
  • Requests with no session cookie at all, if you pass protect_if_cookie_present yourself — 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. AuthServiceProvider deliberately 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) -- so POST /register//login need the CSRF header too, the same as every other unsafe request.
  • A request your own exempt predicate returns True for — for an endpoint that's itself a legitimate, necessarily cross-site POST with 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 — default true.
  • CSRF_COOKIE_NAME — default csrf_token.
  • CSRF_HEADER_NAME — default X-CSRF-Token.
  • form_field_name (constructor argument, not yet an .env setting) — 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):

from zeython.csrf import CsrfMiddleware

app.add_middleware(CsrfMiddleware)

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:

<form method="post" action="/posts">
  @csrf
  ...
</form>

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.