django-tenant-apikeys
Multi-tenant API key authentication for Django. Issue a key per tenant, hash it before it ever touches the database, and gate access with scopes instead of an all-or-nothing flag. Works with Django REST Framework out of the box, and with Django Ninja via a short recipe below.
If you've ever built API key auth for a SaaS product, you've probably
written this same code three or four times: generate a random token, hash
it, store the hash, look it up on every request, and figure out how to show
the raw key to the user exactly once. django-tenant-apikeys is that code,
written once, tested properly, and wired up to whatever tenant model your
project already has.
- Bring your own tenant model. Subclass one abstract model and point it
at whatever
Organization,Account, orWorkspacemodel you already have. No schema opinions beyond that. - Nothing sensitive is stored. Keys are generated with
secrets, kept only as a SHA-256 hash, and checked with a constant-time comparison. The raw key exists for one moment — right after creation — and then it's gone, even from us. - Scopes, not just on/off. Grant a key
"orders:read", a whole namespace with"orders:*", or everything with"*". - Small, framework-agnostic core. The model doesn't know DRF or Ninja
exist.
authentication.pyandpermissions.pyare optional adapters on top of it, so you can build your own integration if neither fits.
Want to see it run before reading further?
examples/simple_saas/ is a minimal Django + DRF
project with the whole flow wired up — clone the repo, pip install -r requirements.txt, python manage.py migrate && python manage.py create_demo_key, and you have a real tenant-scoped API key and a working
curl command in about a minute.
Table of contents
- Installation
- Quick start
- Example project
- How keys work
- Django REST Framework integration
- Django Ninja integration
- Scopes
- Admin integration
- How this compares to other options
- Settings reference
- API reference
- Security notes
- FAQ
- Running the tests
- Contributing
- License
Installation
pip install django-tenant-apikeys[drf]
Extras are additive:
| Extra | Installs | Needed for |
|---|---|---|
drf |
djangorestframework>=3.14 |
TenantAPIKeyAuthentication, HasAPIKeyScope |
ninja |
django-ninja>=1.0 |
The Ninja recipe below |
You don't have to pick either. Installing the bare package still gives you
AbstractTenantAPIKey, generate_api_key, hash_key, and
get_api_key_model — enough to wire up your own auth layer if you're not
on DRF or Ninja.
Quick start
1. Define your concrete key model
AbstractTenantAPIKey ships abstract on purpose. Every project's tenant
model looks different, so you decide how the two connect:
# myapp/models.py
from django.db import models
from django_tenant_apikeys.models import AbstractTenantAPIKey
class Organization(models.Model):
name = models.CharField(max_length=100)
class OrganizationAPIKey(AbstractTenantAPIKey):
tenant = models.ForeignKey(
Organization,
related_name="api_keys",
on_delete=models.CASCADE,
)
That field has to be named tenant. Both TenantAPIKeyAuthentication and
the Ninja recipe below check for an attribute with that exact name and, if
it exists, attach it to the request as request.tenant. Name it something
else and you just lose that one convenience — everything else still works
fine.
2. Point Django at it
# settings.py
INSTALLED_APPS = [
...
"django_tenant_apikeys", # only needed for the admin integration
"rest_framework", # if you're using the DRF integration
"myapp",
]
TENANT_API_KEY_MODEL = "myapp.OrganizationAPIKey"
3. Migrate as usual
python manage.py makemigrations myapp
python manage.py migrate
4. Issue a key
from myapp.models import Organization, OrganizationAPIKey
org = Organization.objects.get(name="Acme Inc.")
instance, raw_key = OrganizationAPIKey.generate_key(
name="CI deploy key",
tenant=org,
scopes=["deployments:write"],
)
print(raw_key)
# tak_live_3f9a2c1d.k7pQ2m1vXyN0...
# show this to the user right now — it's never stored, and can't be
# shown again once you look away
instance is already saved by the time you get it back. What lands in the
database is instance.hashed_key, the SHA-256 digest — not raw_key. Copy
the raw key out of that print statement and put it wherever your app needs
to hand it to the user, because this is the only chance you get.
Example project
examples/simple_saas/ runs the steps above as an
actual project instead of a code snippet: an Organization tenant, an
OrganizationAPIKey, and one protected view (WhoAmIView) that reports back
whatever the library resolved from the request — which tenant, which key,
which scopes.
cd examples/simple_saas
pip install -r requirements.txt
python manage.py migrate
python manage.py create_demo_key # creates a tenant, issues a key, prints a curl command
python manage.py runserver # in another terminal
Run the curl command it prints and you'll get the tenant back in the
response. Its own README also walks
through the failure cases — no header, a garbage key, the wrong scope — so
you can see what each one actually returns.
How keys work
A generated key looks like this:
tak_live_3f9a2c1d.Xk7pQ2m1vYzN0hT8sR4uWjLdEaFbGcHiJk
└──┬──┘ └───┬────┘ └────────────────┬────────────────┘
prefix secret_prefix secret
└──────────┬──────────┘
stored in `prefix` column (indexed, unique, cleartext)
generate_api_key(prefix="tak") builds that and returns
(full_key, key_prefix, hashed_key).
The part before the dot — tak_live_3f9a2c1d — is stored in the prefix
column, in plain text, and it's fine that it is. It doesn't carry enough
entropy to matter on its own; its only job is letting a lookup hit a unique
index instead of hashing every row in the table on every request.
The part after the dot is the actual secret. It never gets written down
anywhere except as sha256(full_key), in the hashed_key column. The one
and only place the full, usable key exists is the return value of
generate_api_key() — logs, the admin, the database, none of them ever see
it.
Django REST Framework integration
# myapp/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from django_tenant_apikeys.authentication import TenantAPIKeyAuthentication
from django_tenant_apikeys.permissions import HasAPIKeyScope
class DeploymentsView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [HasAPIKeyScope]
required_scopes = ["deployments:write"]
def post(self, request):
# request.auth is the authenticated OrganizationAPIKey instance
# request.tenant is request.auth.tenant, attached automatically
request.tenant.deployments.create(...)
return Response(status=201)
Or skip the per-view wiring and set it globally:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"django_tenant_apikeys.authentication.TenantAPIKeyAuthentication",
],
}
Clients send:
Authorization: Api-Key tak_live_3f9a2c1d.Xk7pQ2m1vYzN0hT8sR4uWjLdEaFbGcHiJk
A few things worth knowing about what authenticate() actually does:
- No
Authorizationheader, or one that isn't using theApi-Keyscheme? It returnsNoneand gets out of the way, so any other authenticator you have configured gets a turn. - Scheme matches but the key is missing, malformed, unrecognized, tampered
with, deactivated, or expired? It raises
AuthenticationFailed— a plain 401, same as DRF's built-in authenticators. - On success it returns
(None, api_key_instance). ThatNonewhere a DjangoUserwould normally sit is intentional — an API key authenticates an integration, not a person, sorequest.userstays anonymous andrequest.authis where the key instance actually lives.
Running more than one kind of key in the same project? Subclass instead of juggling settings:
class PartnerAPIKeyAuthentication(TenantAPIKeyAuthentication):
model = PartnerAPIKey
Scoped permissions
HasAPIKeyScope reads required_scopes off the view and checks each entry
against request.auth.has_scope(...):
class OrdersView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [HasAPIKeyScope]
required_scopes = ["orders:read", "orders:write"] # all of these, not any
No required_scopes on the view, or an empty list? Then any authenticated
key gets in — the check is opt-in per view.
Django Ninja integration
There's no dedicated Ninja module in this package, and honestly there
doesn't need to be — Ninja's
APIKeyHeader is small
enough to write directly against the same model methods DRF uses:
# myapp/auth.py
from ninja.security import APIKeyHeader
from django_tenant_apikeys.models import get_api_key_model
class TenantAPIKeyAuth(APIKeyHeader):
param_name = "Authorization"
openapi_scheme = "apikey"
def authenticate(self, request, key):
if not key or not key.startswith("Api-Key "):
return None
raw_key = key.removeprefix("Api-Key ").strip()
model = get_api_key_model()
key_prefix, _sep, _secret = raw_key.partition(".")
try:
api_key = model.objects.get(prefix=key_prefix)
except model.DoesNotExist:
return None
if not api_key.verify_key(raw_key):
return None
if not api_key.is_active or api_key.is_expired:
return None
api_key.record_usage()
if hasattr(api_key, "tenant"):
request.tenant = api_key.tenant
return api_key
# myapp/api.py
from ninja import NinjaAPI
from myapp.auth import TenantAPIKeyAuth
api = NinjaAPI(auth=TenantAPIKeyAuth())
@api.post("/deployments")
def create_deployment(request):
if not request.auth.has_scope("deployments:write"):
return 403, {"detail": "missing required scope"}
request.tenant.deployments.create(...)
return {"status": "ok"}
get_api_key_model(), verify_key(), has_scope() — all of it works the
same regardless of which framework is calling it. Only the header-parsing
glue changes.
Scopes
scopes is just a JSON list of strings on the key. has_scope() checks it
three ways, in order:
key.scopes = ["orders:read"]
key.has_scope("orders:read") # True — exact match
key.has_scope("orders:write") # False
key.scopes = ["*"]
key.has_scope("anything:at:all") # True — global wildcard
key.scopes = ["orders:*"]
key.has_scope("orders:read") # True — namespaced wildcard
key.has_scope("orders:write") # True
key.has_scope("billing:read") # False — different namespace
key.has_scope("orders") # False — wildcard needs the "orders:" prefix
Admin integration
# myapp/admin.py
from django.contrib import admin
from django_tenant_apikeys.admin import TenantAPIKeyAdmin
from myapp.models import OrganizationAPIKey
@admin.register(OrganizationAPIKey)
class OrganizationAPIKeyAdmin(TenantAPIKeyAdmin):
pass
That gets you a list view with a masked key column
(tak_live_3f9a2c1d.••••••••••••) instead of anything secret, and a
create flow that shows the raw key exactly once, in a dismissible admin
message, right after you save. It's never written to a form field, so it
can't come back later in the change view no matter who's looking. prefix,
hashed_key, and created_at are read-only for the same reason — there's
nothing useful an admin user could safely do by editing them.
It's a normal ModelAdmin underneath, so list_display, fieldsets,
custom permissions — all of that layers on top the way you'd expect.
How this compares to other options
The most established alternative in the Django ecosystem is
djangorestframework-api-key,
and it's a solid, battle-tested package. If all you need is a key that's
either active or not, it's the safer, more established pick.
django-tenant-apikeys exists for a narrower, specific shape of problem:
you have tenants, and a key belongs to one of them. The tenant attachment
on request.tenant and the scope system ("orders:*"-style wildcards, not
just a boolean) are built around that use case rather than added on top of
a more generic one. If you're not multi-tenant, or you don't need
per-key permissions, you probably don't need this package — and that's a
fine reason to use something else instead.
Settings reference
| Setting | Required | Description |
|---|---|---|
TENANT_API_KEY_MODEL |
Yes* | "app_label.ModelName" string pointing at your concrete key model. Read by get_api_key_model() / TenantAPIKeyAuthentication.get_model(). |
* Only if you rely on the default model resolution. Subclass
TenantAPIKeyAuthentication with an explicit model attribute, or don't
call get_api_key_model() at all, and you can skip this setting entirely.
API reference
django_tenant_apikeys.models
generate_api_key(prefix: str = "tak") -> tuple[str, str, str]— returns(full_key, key_prefix, hashed_key).hash_key(raw_key: str) -> str— SHA-256 hex digest ofraw_key.get_api_key_model() -> type[AbstractTenantAPIKey]— resolvessettings.TENANT_API_KEY_MODEL; raisesImproperlyConfiguredif it's unset or invalid.AbstractTenantAPIKey— abstract model with fieldsname,prefix,hashed_key,scopes,is_active,created_at,expires_at,last_used_at, plus:generate_key(cls, *, prefix="tak", **kwargs) -> tuple[instance, raw_key]verify_key(self, raw_key: str) -> boolhas_scope(self, required_scope: str) -> boolrecord_usage(self) -> None— updateslast_used_at, throttled byLAST_USED_THRESHOLD(5 minutes by default) so a hot endpoint isn't a write on every request.TenantAPIKeyAuthenticationcalls this on every successful authentication; call it yourself from a custom integration.is_expired/is_validproperties
TenantAPIKeyManager(.objects) —get_from_key(raw_key)for an indexed prefix lookup (still callverify_key()on what it returns), andget_usable_keys()for active, unexpired keys only.
django_tenant_apikeys.authentication (needs [drf])
TenantAPIKeyAuthentication— the DRFBaseAuthenticationsubclass described above.
django_tenant_apikeys.permissions (needs [drf])
HasAPIKeyScope— the DRFBasePermissionsubclass described above.
django_tenant_apikeys.admin
TenantAPIKeyAdmin— theModelAdminbase class described above.
Security notes
The hash is unsalted SHA-256, and that's deliberate, not an oversight.
The input already carries 256 bits of entropy from secrets.token_urlsafe
— it's nothing like a low-entropy human password, so it isn't exposed to
dictionary or rainbow-table attacks the way a password hash would be.
Salting a value that's already this random buys you nothing here.
Comparisons run in constant time. verify_key() uses
secrets.compare_digest instead of ==, so an attacker can't use response
timing to work out a key one byte at a time.
The raw key can't be recovered, by design. Once generate_key()
returns it, that's the only copy that will ever exist. If your app loses
it before showing it to the user, the fix is issuing a new key — not
digging through the database.
Revoke by deactivating, not deleting. is_active=False shuts a key off
immediately while keeping the audit trail intact, and
TenantAPIKeyAuthentication rejects a deactivated key with the exact same
error it uses for an invalid one — so a revoked key doesn't leak the fact
that it used to be valid.
This library doesn't rate-limit anything. It verifies a presented key; it has no opinion on how many times someone gets to guess wrong. Pair it with a DRF throttle class if brute-force protection matters for your API.
FAQ
Is this production-ready?
The core is small, has 100% test coverage, and is type-checked with mypy
in strict mode. That said, it's a young package (v0.1.0) without much of
a track record yet — read the code before you bet a production auth path
on it, same as you should with any new dependency.
Do I need Django REST Framework to use this?
No. The model, hashing, and scope logic have zero DRF dependency. The
[drf] extra only adds TenantAPIKeyAuthentication and HasAPIKeyScope
on top. Plain Django views, or Ninja via the recipe above, both work
without it.
How do I rotate a key?
There's no built-in rotate() yet — issue a new key with
Model.generate_key(...), update whatever's using the old one, then set
is_active=False on the old row once the rollover is done.
Can a key have more than one tenant?
Not out of the box — tenant is a single ForeignKey. If you need a key
shared across several tenants, model that relationship yourself; the
library doesn't assume anything about how tenant is defined beyond its
name.
Async views?
TenantAPIKeyAuthentication.authenticate() is synchronous, matching DRF's
own authentication classes, which don't have first-class async support
either. Wrap the lookup in sync_to_async if you're calling it from async
code directly.
Running the tests
git clone https://github.com/stackadnan/django-tenant-apikeys
cd django-tenant-apikeys
pip install -e ".[dev]"
pytest --cov=django_tenant_apikeys --cov-report=term-missing
The suite runs against an in-memory SQLite database (tests/settings.py),
with concrete subclasses of AbstractTenantAPIKey defined in
tests/models.py — the abstract model itself can't be instantiated or
queried directly, so something concrete has to stand in for it.
Contributing
Issues and pull requests are welcome. If you're changing behavior, add a
test for it, and run ruff check ., mypy django_tenant_apikeys, and
pytest before opening the PR — that's exactly what CI checks on every
push, so you'll see the same result either way.
License
Metadata
Release files for django-tenant-apikeys 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_tenant_apikeys-0.2.0.tar.gz | 21.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_tenant_apikeys-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 40.5 kB
Release files / django_tenant_apikeys-0.2.0.tar.gz
| Download URL | django_tenant_apikeys-0.2.0.tar.gz |
|---|---|
| Size | 21.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f55f375443e2ad430a814afae257884c935b0d473b6a75a3a1c120151790699a
|
|
BLAKE2b-256 checksum How to use checksums |
0748459284b3a8a4a2a0814bb26a3e5338f6882ec1163908a93926001a5a9dc6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.
Transparency logRelease files / django_tenant_apikeys-0.2.0-py3-none-any.whl
| Download URL | django_tenant_apikeys-0.2.0-py3-none-any.whl |
|---|---|
| Size | 18.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4fd5cf1375a43db70c8196dd22b95bcfed8b560968658d9b7a54aeca6fe5f1f0
|
|
BLAKE2b-256 checksum How to use checksums |
28bdf31a78db86807460bc8dd9275b92e25f19019174e9833bcfeb5331b80814
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.
Transparency log