django-tenant-apikeys
Pluggable multi-tenant API key authentication for Django, with first-class support for Django REST Framework and Django Ninja.
- Bring your own tenant model. Subclass one abstract model and point it
at whatever
Organization,Account, orWorkspacemodel your project already has. - Secure by construction. Keys are generated with
secrets, stored only as a SHA-256 hash, and verified with a constant-time comparison. The raw key is shown to the caller exactly once, at creation time, and is not recoverable afterwards — not even by you. - Scope-based permissions. Grant keys fine-grained scopes
(
"orders:read"), namespaced wildcards ("orders:*"), or full access ("*"). - Framework-agnostic core. The model and its methods don't depend on DRF
or Ninja at all —
authentication.pyandpermissions.pyare thin, optional adapters on top of it.
Table of contents
- Installation
- Quick start
- How keys work
- Django REST Framework integration
- Django Ninja integration
- Scopes
- Admin integration
- Settings reference
- API reference
- Security notes
- Running the tests
- Contributing
- License
Installation
pip install django-tenant-apikeys[drf]
Extras are additive and optional:
| Extra | Installs | Needed for |
|---|---|---|
drf |
djangorestframework>=3.14 |
TenantAPIKeyAuthentication, HasAPIKeyScope |
ninja |
django-ninja>=1.0 |
The Ninja recipe below |
Installing neither extra still gives you AbstractTenantAPIKey,
generate_api_key, hash_key, and get_api_key_model — everything needed
to build your own integration.
Quick start
1. Define your concrete key model
AbstractTenantAPIKey is abstract on purpose: every project's tenant model
is different, so you link the two yourself.
# 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,
)
The tenant field name is significant: both TenantAPIKeyAuthentication
and the Django Ninja recipe below look for an attribute literally named
tenant and, if present, attach it to the request as request.tenant. Call
it something else and that convenience is simply skipped — everything else
still works.
2. Configure settings
# settings.py
INSTALLED_APPS = [
...
"django_tenant_apikeys", # only needed for the admin integration
"rest_framework", # if using the DRF integration
"myapp",
]
TENANT_API_KEY_MODEL = "myapp.OrganizationAPIKey"
3. Migrate
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 now; it is
# never stored and can't be shown again
instance is already saved. Only instance.hashed_key (its SHA-256 digest)
is persisted — capture raw_key here or it's gone for good.
How keys work
A generated key looks like:
tak_live_3f9a2c1d.Xk7pQ2m1vYzN0hT8sR4uWjLdEaFbGcHiJk
└──┬──┘ └───┬────┘ └────────────────┬────────────────┘
prefix secret_prefix secret
└──────────┬──────────┘
stored in `prefix` column (indexed, unique, cleartext)
generate_api_key(prefix="tak")returns(full_key, key_prefix, hashed_key).key_prefix(tak_live_3f9a2c1d) is stored in cleartext in theprefixcolumn. It carries no meaningful secrecy on its own — it exists purely so a request can be matched to a row with a single indexedWHERE prefix = ?lookup, instead of hashing and comparing against every row in the table.hashed_keyissha256(full_key), stored in thehashed_keycolumn.- The only place
full_key(the actual secret) ever exists is the return value ofgenerate_api_key()/AbstractTenantAPIKey.generate_key(). Nothing in this library writes it to the database, logs, or the admin.
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 wire it globally:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"django_tenant_apikeys.authentication.TenantAPIKeyAuthentication",
],
}
Callers authenticate with:
Authorization: Api-Key tak_live_3f9a2c1d.Xk7pQ2m1vYzN0hT8sR4uWjLdEaFbGcHiJk
TenantAPIKeyAuthentication.authenticate():
- Returns
Noneif there's noAuthorizationheader, or it doesn't use theApi-Keyscheme — deferring to any other configured authenticator. - Raises
rest_framework.exceptions.AuthenticationFailed(HTTP 401) if the scheme matches but the key is missing, malformed, unknown, tampered with, inactive, or expired. - On success, returns
(None, api_key_instance). The first element isNonerather than a DjangoUser, because an API key authenticates a tenant/integration, not a human —request.userstays anonymous andrequest.authholds the key.
Multiple key models in one project? Subclass instead of relying on the setting:
class PartnerAPIKeyAuthentication(TenantAPIKeyAuthentication):
model = PartnerAPIKey
Scoped permissions
HasAPIKeyScope reads a required_scopes list off the view and checks it
against request.auth.has_scope(...):
class OrdersView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [HasAPIKeyScope]
required_scopes = ["orders:read", "orders:write"] # ALL must be granted
A view without a required_scopes attribute (or an empty one) is open to
any successfully authenticated key.
Django Ninja integration
There's no dedicated Ninja module shipped in this package — Ninja's
APIKeyHeader auth
classes are simple enough that one lives comfortably in your own project,
built directly on the same AbstractTenantAPIKey methods DRF uses:
# myapp/auth.py
from django.utils import timezone
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
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() (and every method on the model) works identically
here — only the HTTP-layer glue differs between frameworks.
Scopes
scopes is a plain JSON list of strings. has_scope() supports three
forms, checked in this 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 requires 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
TenantAPIKeyAdmin:
- Sets
readonly_fields = ("prefix", "hashed_key", "created_at"), so the hash can be inspected (e.g. to confirm a key exists) but never edited. - Generates the prefix/hash pair itself on creation and shows the one-time raw key in a dismissible admin message — it is never written to a form field, so it can't round-trip back into the database or appear in the change view afterwards.
- Lists a
masked_keycolumn (tak_live_3f9a2c1d.••••••••••••) instead of any secret material, so list views stay safe to screen-share.
Add your own list_display, fieldsets, etc. as usual — TenantAPIKeyAdmin
is a normal ModelAdmin subclass.
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 required if you use the default model resolution. Subclassing
TenantAPIKeyAuthentication with an explicit model attribute, or calling
get_api_key_model() never at all, makes it optional.
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 unset or invalid.AbstractTenantAPIKey— abstract model with fieldsname,prefix,hashed_key,scopes,is_active,created_at,expires_at, and:generate_key(cls, *, prefix="tak", **kwargs) -> tuple[instance, raw_key]verify_key(self, raw_key: str) -> boolhas_scope(self, required_scope: str) -> boolis_expired/is_validproperties
TenantAPIKeyManager(.objects) — addsget_from_key(raw_key)(indexed lookup by prefix; still callverify_key()on the result) andget_usable_keys()(active and unexpired).
django_tenant_apikeys.authentication (requires [drf])
TenantAPIKeyAuthentication— DRFBaseAuthenticationsubclass described above.
django_tenant_apikeys.permissions (requires [drf])
HasAPIKeyScope— DRFBasePermissionsubclass described above.
django_tenant_apikeys.admin
TenantAPIKeyAdmin—ModelAdminbase class described above.
Security notes
- Hashing is unsalted SHA-256, deliberately. The input already carries
256 bits of entropy from
secrets.token_urlsafe, so it isn't vulnerable to dictionary or rainbow-table attacks the way a low-entropy user password would be. Salting a value that's already high-entropy adds operational cost without a corresponding security gain here. - Comparison is constant-time.
verify_key()usessecrets.compare_digest, not==, so response timing can't be used to narrow down a guessed key byte by byte. - The raw key is never persisted, logged, or displayed twice. Store it
yourself (e.g. show it once in your UI, or hand it back from an API
response) the moment
generate_key()returns it — this library cannot recover it for you afterwards. - Deactivate, don't just delete. Setting
is_active=Falserevokes a key immediately while preserving an audit trail;TenantAPIKeyAuthenticationrejects inactive keys with the sameAuthenticationFailedused for invalid ones, so revoked keys don't leak information about their own existence.
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 defined in
tests/settings.py, with tests/models.py providing concrete subclasses of
AbstractTenantAPIKey (AbstractTenantAPIKey itself is abstract and can't
be instantiated or queried directly).
Contributing
Issues and pull requests are welcome. Please include tests for any behavior
change, and run ruff check ., mypy django_tenant_apikeys, and pytest
before opening a PR — these are exactly what CI runs on every push.
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_tenant_apikeys-0.1.0.tar.gz.
File metadata
- Download URL: django_tenant_apikeys-0.1.0.tar.gz
- Upload date:
- Size: 21.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
562503f8d32d36e1d2da5d644fb7500a7bbe02e783e870b4d0b9ce640efc590d
|
|
| MD5 |
3f327cf4d7e8736b4a5d7ea8d37fccf4
|
|
| BLAKE2b-256 |
07fc97279d63840ebca9d52f2cf9710f6a628e75f909a787de736c94de639513
|
Provenance
The following attestation bundles were made for django_tenant_apikeys-0.1.0.tar.gz:
Publisher:
publish.yml on stackadnan/django-tenant-apikeys
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_tenant_apikeys-0.1.0.tar.gz -
Subject digest:
562503f8d32d36e1d2da5d644fb7500a7bbe02e783e870b4d0b9ce640efc590d - Sigstore transparency entry: 2567919821
- Sigstore integration time:
-
Permalink:
stackadnan/django-tenant-apikeys@6b87d47cc869ea1733f39ac27b508131c84cf281 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/stackadnan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6b87d47cc869ea1733f39ac27b508131c84cf281 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_tenant_apikeys-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_tenant_apikeys-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f947d4a9d66120ab2ff9737bb10225b85124e4a833a894bb1697ffc173f344ee
|
|
| MD5 |
386b8ea27f0f0b44fde05f904ed00733
|
|
| BLAKE2b-256 |
17797c6004dc4d2dbf10722db4c9810c36002aa98ee7833412629e20ae963a06
|
Provenance
The following attestation bundles were made for django_tenant_apikeys-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on stackadnan/django-tenant-apikeys
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_tenant_apikeys-0.1.0-py3-none-any.whl -
Subject digest:
f947d4a9d66120ab2ff9737bb10225b85124e4a833a894bb1697ffc173f344ee - Sigstore transparency entry: 2567919833
- Sigstore integration time:
-
Permalink:
stackadnan/django-tenant-apikeys@6b87d47cc869ea1733f39ac27b508131c84cf281 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/stackadnan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6b87d47cc869ea1733f39ac27b508131c84cf281 -
Trigger Event:
release
-
Statement type: