Django 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, an environment column, a computed status column, and a bulk revoke action — without writing any of it yourself.
The one-time secret reveal¶
Stripe, GitHub, and AWS all show you a new secret exactly once, at creation,
and never again. That's a direct consequence of only ever storing a hash:
if the admin could show the secret later, it would mean the secret was
retrievable from the database, which defeats the point of hashing it.
TenantAPIKeyAdmin.save_model() implements the same pattern:
def save_model(self, request, obj, form, change):
if change:
super().save_model(request, obj, form, change)
return
full_key, key_prefix, hashed_key = generate_api_key()
obj.prefix = key_prefix
obj.hashed_key = hashed_key
super().save_model(request, obj, form, change)
self.message_user(request, self._one_time_key_message(full_key), level=messages.WARNING)
The change branch matters as much as the create branch — editing an
existing key never regenerates it. Without that check, renaming a key or
flipping is_active in the admin would silently mint a new secret and
invalidate whatever the client already has.
On create, generate_api_key() runs server-side, ignoring whatever the
submitted form contained. The raw key is shown exactly once, in a
dismissible warning message right after you save — message_user() with
Django's messages framework, not a custom template, so it survives the
redirect after save and gets styled by whatever admin theme you're running.
It's built with format_html, which HTML-escapes its arguments, so nothing
in the generated key can break out of the <code> tag it renders inside.
There's no "view secret" action afterward, by design. If you lose it before copying it, the fix is rotating the key, not digging through the database.
What's masked, and what's read-only¶
@admin.display(description="Key")
def masked_key(self, obj: AbstractTenantAPIKey) -> str:
return f"{obj.prefix}.{'•' * 12}"
The list view never renders anything derived from hashed_key — prefix
plus a fixed run of bullet characters, which is enough to recognize which
key a row is without exposing anything secret.
readonly_fields covers prefix, hashed_key, created_at,
last_used_at, revoked_at, and revoked_reason — on the individual
change form, these render as plain text, not inputs. There's no form field
an admin user could tamper with to make the row accept an attacker-chosen
hash, or backdate created_at, or forge revoked_at.
The status column¶
@admin.display(description="Status")
def status(self, obj: AbstractTenantAPIKey) -> str:
if obj.is_expired:
return "Expired"
if not obj.is_active:
return "Revoked" if obj.revoked_at else "Inactive"
return "Active"
Revoked only shows once revoke() has actually run (it sets
revoked_at) — distinct from Inactive, which is what an admin user
unchecking the "active" checkbox directly produces. Expired takes
precedence over both, since an expired-but-still-is_active key is
unusable either way.
Bulk revocation¶
A Revoke selected API keys admin action is registered by default —
select rows in the list view, revoke them all with
reason="revoked via admin", skipping any that are already inactive.
Editable fields¶
rate_limit, rate_limit_window, allowed_ips, metadata, environment,
scopes, name, and expires_at are all ordinary editable fields on the
change form — nothing about them needed a custom widget. allowed_ips and
metadata render as Django's standard JSONField form widget (a validated
JSON textarea), the same as scopes already did before 0.4.0.
Filtering and searching¶
list_display = ("name", "masked_key", "environment", "status", "created_at", "expires_at", "last_used_at")
list_filter = ("is_active", "environment", "created_at", "expires_at")
search_fields = ("name", "prefix")
Extending it for your concrete model¶
TenantAPIKeyAdmin is meant to be subclassed, not used directly. Extend
list_display/list_filter rather than replacing them, to keep the masked
key column and the readonly protections intact:
@admin.register(OrganizationAPIKey)
class OrganizationAPIKeyAdmin(TenantAPIKeyAdmin):
list_filter = TenantAPIKeyAdmin.list_filter + ("tenant",)
It's a normal ModelAdmin underneath — fieldsets, custom permissions,
inline models, all of that layers on top the way you'd expect from any
other ModelAdmin subclass.
Next¶
- Key lifecycle — rotating and revoking from a script or the shell, not just the admin.
- Management commands — the same operations, without opening the admin at all.