Auth with Django Ninja (after Django + uv)

Prerequisite: django-uv.md. Prefer django-environment.md so API keys/secrets are not hard-coded. If a browser frontend on another origin will call protected routes, do django-cors.md first (and read the credentials note there before choosing session cookies).

End state: one public route (/api/health) and one protected route that returns 401 without a key and 200 with a valid key. You understand when to pick API key vs session vs JWT later.

Suggested order: after environment (and after CORS if the client is a browser on another host). Independent of Postgres and of a second app — but put protected business routes on the app that owns them.

How to use this: implement API key header auth end-to-end. Skim the “other options” section; do not install JWT libraries until you have a reason.


0. Pick a model (30 seconds)

Approach Good when Avoid when
API key (this runbook) Scripts, server clients, simple “share a secret” access You need per-user login UX in a browser
Django session (django_auth) Same-site browser app, users already log in via Django Cross-origin SPA without careful CORS + CSRF
JWT (extra package) Mobile / SPA needs stateless Bearer tokens You do not yet have that client — adds moving parts

Start with API key. It uses Ninja’s built-ins and teaches auth= on routes. Swap strategy later without throwing away your routers.


1. Store a key in .env

In .env:

API_KEY=dev-change-me

Use a long random value for anything beyond a toy:

uv run python -c "import secrets; print(secrets.token_urlsafe(32))"

In settings.py:

API_KEY = env("API_KEY")

(or os.environ["API_KEY"] if you skip django-environ)

  • [ ] Key is in .env, read in settings, listed as empty in .env.example.

This is a learning setup (one global key). Real multi-client keys usually live in the database; the authenticate hook is the same shape.


2. Define an auth class

Create core/auth.py:

from django.conf import settings
from ninja.security import APIKeyHeader


class ApiKey(APIKeyHeader):
    param_name = "X-API-Key"

    def authenticate(self, request, key):
        if key == settings.API_KEY:
            return key
        return None
  • [ ] Save.

How Ninja uses this: if authenticate returns a truthy value, the request is authenticated and that value is available as request.auth. If it returns None, Ninja responds 401.


3. Protect a route; leave health public

In core/api.py:

from ninja import Router

from .auth import ApiKey

router = Router()
api_key = ApiKey()


@router.get("/health")
def health(request):
    return {"status": "ok"}


@router.get("/me", auth=api_key)
def me(request):
    return {"auth": request.auth}
  • [ ] Save.

Why auth= on the route: global NinjaAPI(auth=…) locks everything. Route-level auth keeps health/docs-friendly probes public.

You can also pass auth=api_key into Router() or add_router(..., auth=api_key) later when a whole app should default to protected.


4. Prove it

uv run python manage.py runserver

Unauthenticated:

curl -i http://127.0.0.1:8000/api/me
  • [ ] Status 401.

Authenticated (use the same key as .env):

curl -i -H "X-API-Key: dev-change-me" http://127.0.0.1:8000/api/me
  • [ ] Status 200 and JSON that echoes the key (or whatever you returned).

Health still open:

curl http://127.0.0.1:8000/api/health
  • [ ] Still {"status":"ok"} with no header.

Open /api/docs — the protected operation should show the API key security scheme so you can “Authorize” in Swagger and try it there too.


Checkpoint — you are done when

  • [ ] Public and protected routes behave differently
  • [ ] Key comes from env, not a string literal in auth.py
  • [ ] You can explain: authenticate return value → request.auth, None → 401

Stop. Do not bolt on JWT + social login + permissions matrices in the same sitting.


Other options (read, don’t implement unless needed)

Session auth (browser, usually same site)

from ninja.security import django_auth

@router.get("/me", auth=django_auth)
def me(request):
    return {"user_id": request.auth.id}

Requires a logged-in Django session cookie. Cross-origin needs CORS credentials + CSRF care (see Ninja’s CSRF docs). Prefer API keys/Bearer tokens for a separate SPA until you are ready for that complexity.

JWT later

When a SPA/mobile client needs short-lived Bearer tokens, look at a JWT library that fits plain NinjaAPI + Router (or accept ninja-extra if you choose that ecosystem). Wire token obtain/refresh routes, then auth= with a JWT class on protected routers. Do that in its own focused session — not as a side quest here.


If you get stuck

Symptom Likely cause
Always 401 Header name mismatch (X-API-Key vs what you send), wrong key, typo in .env
Always 200 with no key Forgot auth=api_key on the route / wrong router mounted
API_KEY missing at import Env not loaded before settings access
Works in Swagger, not curl Swagger “Authorize” set; curl missing header

Next