Protect a Django API
This quickstart protects a Django API with KeenAuth access tokens. You add the @protect view decorator of the Python SDK, require a scope on one view and test the API offline with the SDK test kit. The example uses Django 5.2 and no database.
The API has three endpoints:
| Endpoint | Requires |
|---|---|
GET /api/public |
nothing |
GET /api/private |
a valid access token for your API |
GET /api/messages |
a valid access token with the messages:read scope |
Before you begin
- A KeenAuth environment. Its issuer looks like
https://your-environment.eu.keenauth.app. - An API registered in the environment (Create an API). Its
identifier, for examplehttps://api.example.com, is the audience of the tokens your API accepts. - The
messages:readscope on that API, granted to the application that calls it. - Python 3.10 or later.
1. Install the SDK
pip install "keenauth-python[django]"
The SDK is not published yet. Until it is, the example installs it from the SDK source. The example pins every package to an exact version in requirements.txt.
2. Configure
project/settings.py reads every value from environment variables:
| Variable | Required | Meaning |
|---|---|---|
DJANGO_SECRET_KEY |
yes | Django's secret key; generate one and never commit it |
DJANGO_ALLOWED_HOSTS |
no | Comma-separated host names, default localhost,127.0.0.1 |
KEENAUTH_ISSUER |
yes | Your environment's issuer |
KEENAUTH_AUDIENCE |
yes | Your API's identifier |
PUBLIC_BASE_URL |
yes | The URL clients call (see Behind a proxy) |
KEENAUTH_WEBHOOK_SECRET |
recommended | The idp_whsec_ signing secret of the webhook endpoint that delivers token.revoked (two, comma separated, while you rotate it). Without it the API never learns of revoked tokens |
3. Protect your views
@protect(protector) requires a valid token and puts the verified claims in request.auth; scopes=[...] also requires scopes. A failed check returns an application/problem+json response before the view runs.
@require_GET
def public(request: HttpRequest) -> JsonResponse:
# No token needed.
return JsonResponse({"message": "Hello from a public endpoint."})
@require_GET
@protect(protector)
def private(request: HttpRequest) -> JsonResponse:
# Any valid access token for this API; the verified claims are in request.auth.
return JsonResponse({"message": "Hello from a private endpoint.", "sub": request.auth.subject})
@require_GET
@protect(protector, scopes=["messages:read"])
def messages(request: HttpRequest) -> JsonResponse:
# A valid access token that also carries the messages:read scope.
return JsonResponse({"messages": ["Hello from a scoped endpoint."]})
api/auth.py builds the protector from settings at startup (ApiConfig.ready in api/apps.py), so a missing setting stops the server before the first request. The verifier fetches the signing keys from your environment the first time it sees a token.
@cache
def get_protector() -> Protector:
if not settings.KEENAUTH_ISSUER or not settings.KEENAUTH_AUDIENCE or not settings.KEENAUTH_PUBLIC_BASE_URL:
raise ImproperlyConfigured("KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required")
# The verifier refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
verifier = Verifier(VerifierConfig(issuer=settings.KEENAUTH_ISSUER, audience=settings.KEENAUTH_AUDIENCE))
return Protector(
verifier,
public_base_url=settings.KEENAUTH_PUBLIC_BASE_URL,
revocation=revocation,
# The error never contains the token or claim values; never log the Authorization or DPoP header.
on_error=lambda err: log.warning("rejected: %s", err),
)
@cache
def get_webhook() -> Optional[RevocationWebhook]:
"""The token.revoked webhook, or None when KEENAUTH_WEBHOOK_SECRET is not set. A malformed secret raises
WebhookSecretError (without echoing it), so ApiConfig.ready stops the server at startup."""
if not settings.KEENAUTH_WEBHOOK_SECRETS:
return None
return RevocationWebhook(settings.KEENAUTH_WEBHOOK_SECRETS, revocation)
class ProtectorProxy:
"""Hands each request to `get_protector()`, so the `@protect` decorators can be applied at import time."""
def authenticate(self, req: RequestInfo, requirement: Optional[Requirement] = None) -> AccessClaims:
return get_protector().authenticate(req, requirement)
protector = ProtectorProxy()
4. Run and call the API
export DJANGO_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')"
export KEENAUTH_ISSUER=https://your-environment.eu.keenauth.app
export KEENAUTH_AUDIENCE=https://api.example.com
export PUBLIC_BASE_URL=https://api.example.com
python manage.py runserver 8080
runserver is for development only; in production run project.wsgi under a WSGI server.
For a first call, get a token with the client credentials grant of a machine-to-machine application that has the messages:read scope on your API:
ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d resource=https://api.example.com \
-d scope=messages:read \
https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)
curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"
5. Test without a network
keenauth.testing mints tokens signed with a public test key, and testing.verifier accepts only those tokens. The tests replace get_protector with a protector that uses that verifier, so they need no network, no KeenAuth account and no test switch in the settings. See Offline test kit.
class ProtectedAPITest(SimpleTestCase):
def setUp(self) -> None:
self.cache = MemoryRevocationCache()
test_protector = Protector(testing.verifier(audience=AUDIENCE), public_base_url=BASE_URL, revocation=self.cache)
patcher = mock.patch.object(auth, "get_protector", return_value=test_protector)
patcher.start()
self.addCleanup(patcher.stop)
def test_scope(self) -> None:
self.assert_problem(self.get("/api/messages", testing.mint_access_token(AUDIENCE)), 403, "insufficient_scope",
'scope="messages:read"')
tok = testing.mint_access_token(AUDIENCE, claims={"scope": "profile messages:read"})
self.assertEqual(self.get("/api/messages", tok).status_code, 200)
set -a; . ./.env.example; set +a
DJANGO_SECRET_KEY=insecure-test-only python manage.py test
The example's tests check that a valid token is accepted and that a missing, expired or wrong-audience token, a missing scope, a token in the URL query and a revoked token are rejected with the statuses below. They also send a DPoP-bound token with a proof for PUBLIC_BASE_URL, and check that the production protector refuses the test issuer. The tests also send signed token.revoked deliveries: a valid one revokes the token, a forged, tampered or stale one gets 400 and revokes nothing, and a repeated one has one effect.
Responses
| Request | Status | code in the body |
|---|---|---|
No Authorization header |
401 |
missing_token |
| Expired token, token for another API, bad signature, revoked token | 401 |
invalid_token |
| Token without the required scope | 403 |
insufficient_scope |
Token in the URL query (?access_token=) |
400 |
invalid_request |
The body is application/problem+json and the WWW-Authenticate header carries the standard challenge; Django joins several challenges into one header. The full table is in Protecting your API with middleware.
Behind a proxy
If clients use DPoP, the middleware checks that each proof was made for the URL the client called. Set PUBLIC_BASE_URL to the external URL clients call, for example https://api.example.com, also behind a proxy or load balancer. It is required and must use https (http only for localhost, 127.0.0.1 or [::1]); the app refuses to start without it. The middleware never reads X-Forwarded-* headers itself; Django applies them only if you set SECURE_PROXY_SSL_HEADER or USE_X_FORWARDED_HOST, and with PUBLIC_BASE_URL set the checked URL does not depend on them.
Revoked tokens
An access token stays valid until it expires, so KeenAuth tells your API when tokens are revoked. The example passes an in-memory revocation cache to the protector and serves a token.revoked webhook at POST /webhooks/keenauth (api/webhook.py) that feeds it.
- In the dashboard, create a webhook endpoint with the URL
https://<your host>/webhooks/keenauththat subscribes totoken.revoked, and export its signing secret asKEENAUTH_WEBHOOK_SECRET. - The handler verifies the signature with the SDK on the raw body and answers
400when it is invalid or the timestamp is more than five minutes off. It maps the event's selector (access_token,session,user,client,org,org_user,client_user) to a revocation marker and answers204; the middleware then rejects every matching token issued beforenot_before. - KeenAuth delivers at least once, so the handler applies each
webhook-idonce. If the marker cannot be stored it answers503and KeenAuth delivers the event again. It ignores other event types and selectors that no access token can match (refresh_family,api).
The handler logs the event id, the selector type and the reason, never the body, a signature or the secret. Each worker process has its own cache; with several workers or instances, use a shared store instead. See Revocation cache.
Next steps
- Protecting your API with middleware: Flask, step-up requirements, DPoP and every option.
- Verifying tokens: the verifier under the middleware.
- Offline test kit.