API keys¶
Reference
Machine clients such as CI jobs, integrations and scripts need credentials that can be
limited, rotated and revoked without touching a user's password. ninja_devx.contrib.apikeys
provides scoped keys through Ninja's own authentication classes.
from ninja.security import django_auth
from ninja_devx.contrib.apikeys.api import APIKeyController
from ninja_devx.contrib.apikeys.auth import APIKeyAuth, HasScopes, RequiresScope
api = NinjaAPI(auth=[APIKeyAuth(), django_auth]) # keys or sessions
class OrderController(Controller):
options = ControllerOptions(permissions=[HasScopes(allow_unscoped=True)])
@get("/", meta=(RequiresScope("orders:read"),))
def list_orders(self, request: HttpRequest) -> list[Order]: ...
@post("/", meta=(RequiresScope("orders:write"),))
def create(self, request: HttpRequest, payload: OrderIn) -> Order: ...
mount(api, {"/orders": OrderController, "/me/api-keys": APIKeyController})
How keys work¶
- A key looks like
ndx_<prefix>_<secret>. The prefix finds the row. The secret is stored only as a SHA-256 digest and compared in constant time, so a database leak does not reveal usable keys. - The raw key is returned once, when the key is created.
request.authis the key's user, so owner fields, tenancy and permissions work unchanged.current_api_key(request)returns the key itself.- Keys stop working when revoked, expired, or when their user is deactivated.
last_used_atis updated at most once a minute per key.APIKeyAuthreadsX-API-Key;APIKeyBearerreadsAuthorization: Bearer ndx_....- In async operations, authentication costs one thread hop.
Scopes¶
RequiresScope("orders:write") is operation metadata; HasScopes() checks it against the
key's scopes:
| Key scopes | orders:read |
orders:write |
invoices:read |
|---|---|---|---|
["orders:read"] |
allowed | 403 | 403 |
["orders:*"] |
allowed | allowed | 403 |
["*"] |
allowed | allowed | allowed |
Operations without RequiresScope accept any key. Requests authenticated another way
(sessions, JWT) are refused on scoped operations unless allow_unscoped=True, because
they don't carry scopes.
Rate limits per key¶
from ninja_devx.contrib.apikeys.auth import APIKeyRateThrottle
class OrderController(Controller):
options = ControllerOptions(throttle=[APIKeyRateThrottle("1000/hour")])
Each key has its own budget. A key's rate_limit field ("10000/hour" for a partner, set
in the admin, create_api_key(rate_limit=...) or devx_apikey create --rate) overrides the
throttle's default. Requests without an API key pass this throttle, so combine it with
UserRateThrottle or AnonRateThrottle. Responses carry RateLimit-* headers.
Managing keys¶
APIKeyController lets users manage their own keys through session or other user
authentication. Requests authenticated by APIKeyAuth or APIKeyBearer receive 403
on all key-management operations, including tokens with *. Tokens cannot delegate
credentials or change their own authorization limits:
| Route | Description |
|---|---|
GET / |
items and count for the user's keys (never the secret) |
POST / |
{"name", "scopes", "expires_at"} → the key, with key shown once (201) |
DELETE /{key_id} |
revoke (204) |
Set grantable_scopes = ("orders:read", "orders:write") on a subclass to restrict what
users may request (422 otherwise).
From code or the command line:
key, raw = create_api_key(
user, "CI deploys", scopes=["orders:*"], expires_at=now() + timedelta(days=90)
)
rotated, new_raw = rotate_api_key(key, scopes=["orders:read"]) # same row, new secret
revoke_api_key(key)
manage.py devx_apikey create --user ada --name "CI deploys" --scope orders:read --scope orders:write --days 90
manage.py devx_apikey rotate --prefix 3f9a1c2b7d4e --days 90
manage.py devx_apikey revoke --prefix 3f9a1c2b7d4e
rotate_api_key replaces the secret in place: the old value stops working immediately and
the row keeps its owner, name and creation time. Omit a parameter to keep it.
In the admin, keys can be searched, edited (name, scopes, rate limit, expiry) and revoked with the "Revoke selected API keys" action. They cannot be created there, because the raw key has to be shown once.
Testing¶
def test_scopes(ninja_client, django_user_model):
user = django_user_model.objects.create(username="ci")
_, raw = create_api_key(user, "test", scopes=["orders:read"])
client = ninja_client(OrderController)
assert client.get("/", headers={"X-API-Key": raw}).status_code == 200
assert client.post("/", json={...}, headers={"X-API-Key": raw}).status_code == 403
Key listing accepts page and page_size, defaults to 50 items, and caps each page at 100.