skip to content

Why does a Django Ninja POST guarded by auth=[django_auth, PartnerBearer()] return 403 to a partner sending a valid bearer token, and how do you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. which authenticator runs first
  2. cookie-based means CSRF-checked
  3. a raise ends the chain
  4. reorder or split the routes

basics

~20 s

django_auth is cookie-based, so on unsafe methods it runs Django's CSRF check first and raises HttpError(403) when it fails; the raise ends the chain before PartnerBearer runs. Put PartnerBearer first or give partners a separate router.

solid answer

~40 s

All Django Ninja views are marked `csrf_exempt` for Django's middleware; since 1.0 CSRF is enforced **inside cookie-based authenticators** instead. `django_auth` is a `SessionAuth`, a subclass of `APIKeyCookie(csrf=True)`, and on a POST it runs Django's CSRF check before even reading the session cookie. A server-to-server partner sends no CSRF cookie or token, so the check fails and the authenticator **raises** `HttpError(403, "CSRF check Failed")`. A raise stops the authenticator chain, so `PartnerBearer` is never tried. The fix is `auth=[PartnerBearer(), django_auth]`: browsers never attach an `Authorization` header on their own, so the bearer authenticator declines for them and the session path still gets its CSRF check. Separate routers for partners and for the browser front end are cleaner still. Turning CSRF off on `django_auth` would reopen the hole it exists to close.

code

python · 23 lines
python
from ninja import NinjaAPI, Schema
from ninja.security import HttpBearer, django_auth

api = NinjaAPI()


class PartnerBearer(HttpBearer):
    def authenticate(self, request, token):
        return Partner.objects.filter(token_digest=digest(token), is_active=True).first()


class OrderIn(Schema):
    sku: str
    quantity: int


# Broken for partners on POST: django_auth raises the CSRF 403 before PartnerBearer runs.
# @api.post("/orders", auth=[django_auth, PartnerBearer()])

# Fixed: bearer first; browsers never send a bearer header on their own.
@api.post("/orders", auth=[PartnerBearer(), django_auth])
def create_order(request, payload: OrderIn):
    return {"caller": str(request.auth), "sku": payload.sku}

go deeper

for a junior

Recall that django_auth uses the session cookie, so POSTs through it need Django's CSRF token.

for a middle

Explain that Ninja views are CSRF-exempt at the middleware and that only cookie-based authenticators run the check.

for a senior

Diagnose write-only 403s from authenticator order and a raising CSRF check, and fix them without weakening CSRF for browser sessions.

for a principal

Separate machine and browser API surfaces so each has one authentication model and CSRF exposure is reviewable per router.

## Where CSRF lives in Django Ninja Django's `CsrfViewMiddleware` normally rejects unsafe requests (POST, PUT, PATCH, DELETE) that lack a valid CSRF token. **Django Ninja marks every view it generates as `csrf_exempt`**, so the middleware lets all API requests through. Protection is then applied selectively, by the authenticator: | authenticator | CSRF check on unsafe methods | |---|---| | `APIKeyCookie` subclasses | yes, by default (`csrf=True`) | | `django_auth` (`SessionAuth`), `django_auth_superuser`, `django_auth_is_staff` | yes - they subclass `APIKeyCookie` | | `APIKeyHeader`, `APIKeyQuery` | no | | `HttpBearer`, `HttpBasicAuth` | no | The reasoning is that CSRF abuses credentials a browser attaches automatically, such as cookies; a header the client script must set explicitly is not sent by a forged cross-site form. Before 1.0 this was a global switch, `NinjaAPI(csrf=True)`; that parameter no longer exists and the check is automatic for cookie-based authenticators. ## Why the partner gets 403 With `auth=[django_auth, PartnerBearer()]` on a POST, the sequence is: 1. Ninja calls `django_auth` first, because it is first in the list. 2. Being cookie-based, it runs Django's CSRF logic **before** it looks at the session cookie. The partner's request has no CSRF cookie and no `X-CSRFToken` header, so the check fails. 3. The authenticator raises `HttpError(403, "CSRF check Failed")`. 4. An exception raised by an authenticator **ends the chain**: Ninja hands it to the exception handlers and returns the 403. 5. `PartnerBearer` never runs, even though the token is valid. GET requests from the same partner succeed, because safe methods pass the CSRF check and `django_auth` simply returns `None`, letting the bearer authenticator try. That asymmetry - reads work, writes fail - is the usual clue. ## Fixes - **Reorder**: `auth=[PartnerBearer(), django_auth]`. The bearer authenticator returns `None` when there is no `Authorization: Bearer` header, which is always the case for a forged browser request, so browser traffic still reaches `django_auth` and its CSRF check. - **Split the surfaces**: a partner `Router(auth=PartnerBearer())` and a front-end router using `django_auth`. Each operation then has one authentication story, and the CSRF behaviour is obvious from the router. - **Operation-level `csrf_exempt`**: decorating a view with Django's `csrf_exempt` makes cookie authenticators skip the check for that operation - only acceptable when no browser session should ever be able to call it. - **`csrf=False` on a cookie authenticator**: `APIKeyCookie(csrf=False)` exists for cookies that are not ambient browser credentials. Turning it off for the session authenticator removes CSRF protection from every endpoint that uses it. ## Browser clients still need the token For the session path to work from a single-page front end, the browser must hold Django's CSRF cookie (`csrftoken` by default) and echo it in the `X-CSRFToken` header. Ninja's docs show an endpoint wrapped in Django's `ensure_csrf_cookie` (and `csrf_exempt`) that exists only to set that cookie. How Django validates the token and the Origin header is Django's CSRF machinery, not Ninja's. ## Recognising it and testing for it The response body tells the two failures apart: a CSRF failure in a cookie authenticator produces `{"detail": "CSRF check Failed"}` with 403, while authenticators that simply decline produce `{"detail": "Unauthorized"}` with 401. A 403 with the CSRF message on an endpoint meant for machines is the fingerprint of a cookie authenticator sitting first in the list. Tests with `ninja.testing.TestClient` lock the behaviour in: 1. POST with only an `Authorization: Bearer` header - expect 200. 2. POST with a session cookie and no CSRF token - expect 403. 3. POST with a session cookie, the `csrftoken` cookie and a matching `X-CSRFToken` header - expect 200. The second test is the one that proves CSRF protection survived the fix; without it, a later change to `csrf=False` would pass every other test. ## Mistakes interviewers probe - Believing Ninja has no CSRF protection at all because its views are exempt. - Believing every authenticator checks CSRF, and adding tokens to server-to-server calls. - Treating the authenticator list as "try all, pick any" when a raising authenticator stops it. - Reaching for `csrf=False` on `django_auth` to make a partner integration work. - Looking for `NinjaAPI(csrf=True)`, which belongs to the 0.x releases.

  • Why do GET requests from the same partner succeed with auth=[django_auth, PartnerBearer()]?
    Django's CSRF check accepts safe methods such as GET, so `django_auth` does not raise. It finds no authenticated session user and returns `None`, and Ninja moves on to `PartnerBearer`, which accepts the token. Only unsafe methods hit the failing check and the 403.
  • Is it safe to put PartnerBearer before django_auth for browser users?
    Yes. A cross-site forged request cannot add an `Authorization: Bearer` header - browsers attach cookies automatically, not that header - so `PartnerBearer` returns `None` for it and `django_auth` still runs its CSRF check. Only callers that deliberately send a valid bearer token skip the session path.

saying these in an interview costs you the question

  • Django Ninja has no CSRF protection because all its views are csrf_exempt.
  • Every Ninja authenticator, including HttpBearer, enforces a CSRF token.
  • If one authenticator raises, Ninja still tries the next one in the list.
  • Setting csrf=False on django_auth is a safe fix for partner integrations.
  • CSRF in Django Ninja 1.x is turned on with NinjaAPI(csrf=True).