Scoped API keys¶
Authenticating a request tells you who is calling. Scopes tell you what they're allowed to do — and in a multi-tenant API, that's decided per key, not per tenant. A tenant might issue a read-only key to a reporting integration and a full read-write key to their own backend; both keys belong to the same tenant but should not be able to do the same things.
Scopes live on the key¶
scopes is a plain JSONField — a list of strings:
There's no separate scopes table, no join, no migration needed to add a new
scope string. A scope is just a string your application code agrees to
check for. That's a deliberate simplicity trade-off: you design your own
scope taxonomy (orders:read, billing:write, whatever maps to your
domain) rather than the package imposing one.
Checking a scope¶
has_scope() supports three matching modes:
key.scopes = ["orders:read"]
key.has_scope("orders:read") # True -- exact match
key.has_scope("orders:write") # False -- not granted
key.scopes = ["orders:*"]
key.has_scope("orders:read") # True -- namespaced wildcard
key.has_scope("orders:write") # True
key.has_scope("customers:read") # False -- different namespace
key.has_scope("orders") # False -- wildcard needs the "orders:" prefix
key.scopes = ["*"]
key.has_scope("anything:here") # True -- global wildcard, use sparingly
The namespaced wildcard ("orders:*") is the useful middle ground — it lets
a tenant issue a key scoped to an entire resource without you having to
enumerate every action on it up front. Reserve the global "*" for a
tenant's own internal/admin key, not for anything issued to a third party.
Enforcing scopes¶
Django REST Framework¶
HasAPIKeyScope reads required_scopes off the view and requires all
of them:
class OrdersView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [HasAPIKeyScope]
required_scopes = ["orders:read"]
An empty or missing required_scopes means any authenticated key is
allowed through — HasAPIKeyScope only blocks on scopes it's told to
check. It also returns False outright if request.auth isn't an API key
instance at all, so it's meant to be paired with
TenantAPIKeyAuthentication, not used alone.
Need "either/or" instead of "all of"? That's not built in — write a small
custom permission class that calls has_scope() yourself with any(...).
Varying the required scope per action on a ViewSet:
class OrdersViewSet(viewsets.ModelViewSet):
authentication_classes = [TenantAPIKeyAuthentication]
def get_permissions(self):
self.required_scopes = (
["orders:write"]
if self.action in ("create", "update", "partial_update", "destroy")
else ["orders:read"]
)
return [HasAPIKeyScope()]
Django Ninja¶
There's no Ninja equivalent of HasAPIKeyScope — Ninja doesn't have a
permission-class system to hook into. Check has_scope() directly in the
view body:
@api.get("/orders")
def list_orders(request):
if not request.auth.has_scope("orders:read"):
return 403, {"detail": "missing required scope"}
return request.tenant.orders.all()
See Django Ninja for the full integration, including how this composes with IP and rate-limit checks.
Designing scopes that age well¶
- Namespace by resource, not by endpoint.
orders:readsurvives you adding a new endpoint that also reads orders;list-orders:readdoesn't. - Don't scope what you don't enforce yet. An unused scope string in a
key's
scopeslist that no view checks is a false sense of security — keep the taxonomy driven by actualrequired_scopesin your views. - Treat
["*"]as a privileged, rare grant — audit which keys have it the same way you'd audit superuser accounts. - Scopes don't replace
is_active/expires_at. A key with the right scope butis_active=Falseis rejected at authentication, beforeHasAPIKeyScopeis even consulted. Revoking a key is still the fast path for "this integration should stop entirely"; scopes are for "this integration should only do X." See Key lifecycle.
Next¶
- Key lifecycle — rotating a key without changing its scopes, or overriding them on rotation.
- IP restrictions and rate limiting — the other two per-key policies, checked alongside scopes.