Skip to main content

django-tenant-apikeys

PyPI version Python versions Tests License: MIT Latest on Django Packages

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, or Workspace model 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, and ninja.py are 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

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 Authorization header, or one that isn't using the Api-Key scheme? It returns None and 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). That None where a Django User would normally sit is intentional — an API key authenticates an integration, not a person, so request.user stays anonymous and request.auth is 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 of raw_key.
  • get_api_key_model() -> type[AbstractTenantAPIKey] — resolves settings.TENANT_API_KEY_MODEL; raises ImproperlyConfigured if it's unset or invalid.
  • AbstractTenantAPIKey — abstract model with fields name, 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] — raises ValueError if scopes is passed and isn't a list of non-empty strings.
    • verify_key(self, raw_key: str) -> bool
    • has_scope(self, required_scope: str) -> bool
    • record_usage(self) -> None — updates last_used_at, throttled by LAST_USED_THRESHOLD (5 minutes by default) so a hot endpoint isn't a write on every request. TenantAPIKeyAuthentication and TenantAPIKeyAuth both call this on every successful authentication; call it yourself from a custom integration.
    • revoke(self, *, reason="") -> None / reactivate(self) -> None
    • rotate(self, *, prefix="tak", **overrides) -> tuple[instance, raw_key]
    • is_expired / is_valid properties
  • TenantAPIKeyManager (.objects) — get_from_key(raw_key) for an indexed prefix lookup (still call verify_key() on what it returns), and get_usable_keys() for active, unexpired keys only.

django_tenant_apikeys.authentication (needs [drf])

  • TenantAPIKeyAuthentication — the DRF BaseAuthentication subclass described above.

django_tenant_apikeys.permissions (needs [drf])

  • HasAPIKeyScope — the DRF BasePermission subclass described above.

django_tenant_apikeys.ninja (needs [ninja])

  • TenantAPIKeyAuth — the Ninja APIKeyHeader subclass described above.

django_tenant_apikeys.admin

  • TenantAPIKeyAdmin — the ModelAdmin base 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

MIT

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)

Source distribution for django-tenant-apikeys 0.3.0
File Size Uploaded
django_tenant_apikeys-0.3.0.tar.gz 37.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-tenant-apikeys 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page