django-tenant-apikeys
API keys for multi-tenant Django applications
Multi-tenant API key authentication for Django. Issue a key per tenant, hash it before it ever touches the database, gate access with scopes instead of an all-or-nothing flag, and rotate or revoke it later without touching your own code. Works with Django REST Framework and Django Ninja out of the box.
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"*". - A real lifecycle, not just a boolean.
rotate()issues a replacement and revokes the original in one call;revoke()/reactivate()manage access directly. Management commands cover both from the shell. - Small, framework-agnostic core. The model doesn't know DRF or Ninja
exist.
authentication.py,permissions.py, andninja.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
- Key lifecycle: rotating and revoking keys
- Management commands
- 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 |
TenantAPIKeyAuth |
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
TenantAPIKeyAuth (Ninja) 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", # needed for the admin integration and the management commands
"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
pip install django-tenant-apikeys[ninja]
# myapp/api.py
from ninja import NinjaAPI
from django_tenant_apikeys.ninja 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"}
TenantAPIKeyAuth mirrors TenantAPIKeyAuthentication's rules exactly: an
unknown, tampered, inactive, or expired key never falls through as
anonymous access, record_usage() runs on every success, and
request.tenant gets attached the same way. The one real difference is in
how failure surfaces — Ninja doesn't have DRF's AuthenticationFailed with
a specific message, so authenticate() just returns None for every
rejection reason and Ninja turns that into a 401 on its own.
There's no Ninja equivalent of HasAPIKeyScope — Ninja doesn't have a
permission-class system to hook into the way DRF does, so scope checks
happen inline in the view via has_scope(), as above. get_api_key_model(),
verify_key(), has_scope(), rotate(), revoke() — none of it knows or
cares which framework is calling it.
Need multiple key models, same as the DRF class supports? Subclass and set
model:
class PartnerAPIKeyAuth(TenantAPIKeyAuth):
model = PartnerAPIKey
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
Key lifecycle: rotating and revoking keys
Revoke a key immediately, permanently:
api_key.revoke(reason="compromised") # reason is optional, stored on the row
api_key.is_active # False
This sets the same is_active flag TenantAPIKeyAuthentication already
checks on every request — there's no separate "revoked" gate to fall out of
sync with. revoked_at and revoked_reason are audit metadata; nothing
reads them to decide whether the key still works. reactivate() undoes it:
api_key.reactivate()
api_key.is_active # True again -- but is_expired is independent, so a key
# whose expires_at has already passed stays unusable
Rotate a key to replace it without downtime:
new_key, raw_key = api_key.rotate()
This creates a new row with the same tenant, scopes, and expiration as the
original (override any of them with keyword arguments, e.g.
api_key.rotate(scopes=["orders:*"])), revokes the original with
reason="rotated", and returns the new instance and its raw key — the only
time you'll see it, exactly like generate_key(). The old row isn't
deleted, so it stays visible for audit history; it just can't authenticate
anymore. Rotating an already-inactive or expired key raises ValueError
rather than silently handing out working access from something that was
deliberately shut off.
old_key, raw_key = OrganizationAPIKey.generate_key(name="k", tenant=org)
new_key, new_raw_key = old_key.rotate()
old_key.is_valid # False
new_key.is_valid # True
new_key.tenant == old_key.tenant # True
Management commands
python manage.py tenant_api_key_revoke <prefix> [--reason "why"]
python manage.py tenant_api_key_rotate <prefix>
Both resolve the model via TENANT_API_KEY_MODEL and look a key up by its
prefix (the part before the dot — never the raw secret, which isn't
stored), so neither needs to know what fields your concrete model adds.
tenant_api_key_rotate prints the new raw key once, the same way the admin
and generate_key() do.
There's deliberately no tenant_api_key_create: creating a key needs
whatever fields your concrete model requires — at minimum a tenant — and
this package can't know that generically without either hardcoding an
assumption or degrading into an awkward --field=value interface. Copy the
pattern in
examples/simple_saas/organizations/management/commands/create_demo_key.py
into your own project instead; it's a normal Django management command
written against your own schema.
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, a Status
column (Active / Revoked / Inactive / Expired — Revoked only shows
once revoke() has actually run, so it's distinct from someone just
unchecking the "active" box), and a Revoke selected API keys bulk
action. Key creation 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, created_at, last_used_at, revoked_at, and
revoked_reason are all read-only for the same reason — there's nothing
useful an admin user could safely do by editing them directly.
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,revoked_at,revoked_reason, plus:generate_key(cls, *, prefix="tak", **kwargs) -> tuple[instance, raw_key]— raisesValueErrorifscopesis passed and isn't a list of non-empty strings.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.TenantAPIKeyAuthenticationandTenantAPIKeyAuthboth call this on every successful authentication; call it yourself from a custom integration.revoke(self, *, reason="") -> None/reactivate(self) -> Nonerotate(self, *, prefix="tak", **overrides) -> tuple[instance, raw_key]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.ninja (needs [ninja])
TenantAPIKeyAuth— the NinjaAPIKeyHeadersubclass described above.
django_tenant_apikeys.admin
TenantAPIKeyAdmin— theModelAdminbase class described above.
Management commands
tenant_api_key_revoke <prefix> [--reason TEXT]tenant_api_key_rotate <prefix>
Both described above. Require django_tenant_apikeys in INSTALLED_APPS.
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.
Revocation deactivates, it never deletes. revoke() sets the same
is_active flag authentication already checks — there's no second
enforcement path to keep in sync — and TenantAPIKeyAuthentication /
TenantAPIKeyAuth reject a revoked key with the exact same outcome as an
unknown or expired one. revoked_at and revoked_reason are audit
metadata only; nothing about an authentication response distinguishes
"revoked" from "invalid" from "expired," so a caller probing with guessed
keys can't learn anything about a key's history from how it fails.
Rotation can't accidentally resurrect a dead key. rotate() refuses to
run on a key that's already inactive or expired, and both operations that
make up a rotation (creating the new row, revoking the old one) happen
inside a single transaction.atomic() block.
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 (including against PostgreSQL in CI, not just SQLite), and is type-checked with mypy in strict mode. That said, it's still a young package 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, scope, and lifecycle logic have zero DRF
dependency. The [drf] extra only adds TenantAPIKeyAuthentication and
HasAPIKeyScope on top. Plain Django views, or Ninja via [ninja], both
work without it.
How do I rotate a key?
old_key.rotate(), or python manage.py tenant_api_key_rotate <prefix>
from the shell. Either returns/prints the new raw key once and revokes the
old row (kept, not deleted) with reason="rotated". See
Key lifecycle above.
How do I revoke a compromised key right now?
api_key.revoke(reason="compromised"), the Revoke selected API keys
admin action, or python manage.py tenant_api_key_revoke <prefix> --reason compromised. All three go through the same is_active flag
authentication already checks, so there's no delay or cache to worry
about — the very next request with that key fails.
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. CI also
runs the same suite against PostgreSQL (tests/settings_postgres.py) —
psycopg isn't part of the dev extra, since nothing in the package
itself needs it, so pip install "psycopg[binary]" first if you want to
run that locally: pytest --ds=tests.settings_postgres.
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.3.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.3.0.tar.gz | 37.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_tenant_apikeys-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 62.8 kB
Release files / django_tenant_apikeys-0.3.0.tar.gz
| Download URL | django_tenant_apikeys-0.3.0.tar.gz |
|---|---|
| Size | 37.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
305051ba3d854d70a8f26bbc9a85970b3b1616c511fea7fe8226c55c89a12c05
|
|
BLAKE2b-256 checksum How to use checksums |
ae34032c90cef8dcafc83675e106fa2d60ba0f8c182b7fa101c3aca026d2f0ea
|
| 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 Sep 2, 2026.
Transparency logRelease files / django_tenant_apikeys-0.3.0-py3-none-any.whl
| Download URL | django_tenant_apikeys-0.3.0-py3-none-any.whl |
|---|---|
| Size | 25.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
52336c65d375357707bf609b10f5487751f346651bf4f481c960aaf9f1b9984e
|
|
BLAKE2b-256 checksum How to use checksums |
823d5c5f2d36a51e272478b0be0c4ef6890751c70e4649e6fae0464baf40ea5d
|
| 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 Sep 2, 2026.
Transparency log