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:
authenticatereturn 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
- New feature module with its own router (optionally
auth=on that router) →django-second-app.md