Django API key security¶
What this package actually does to keep a key safe, the reasoning behind each decision, and — just as important — what it explicitly does not protect against. Read this before betting a production auth path on it, same as you should with any new dependency. See also SECURITY.md in the repository for how to report a vulnerability.
Generation¶
Keys are generated with secrets.token_hex()/secrets.token_urlsafe() —
Python's cryptographically secure random source, not random (a Mersenne
Twister, predictable enough to be reconstructed from its own output, and
never appropriate for anything security-sensitive). The secret portion
carries 256 bits of entropy.
Storage: unsalted SHA-256, deliberately¶
The hash is a single, unsalted SHA-256 pass (hash_key()), and that's a
decision, not an oversight. Salted, deliberately-slow password hashers
(bcrypt, PBKDF2, argon2) exist to slow down brute-forcing a low-entropy
secret — a human-chosen password drawn from a small effective keyspace. The
secret half of an API key here already has 256 bits of entropy from
secrets.token_urlsafe(). There's no dictionary or rainbow-table attack
that applies to it, so salting buys nothing, and a deliberately slow hash
would only add real cost to every authenticated request without adding
real protection.
The raw key can't be recovered, by design. Once generate_key()
returns it, that's the only copy that will ever exist — not in the
database, not in logs, not in the admin, ever again. If your app loses it
before showing it to the user, the fix is issuing a new key
(Key lifecycle), not digging through the database.
The prefix/secret split (see Authentication) means
the cleartext prefix column carries no real entropy on its own — logging
it, showing it in the admin, or including it in an error message costs
nothing, since it's not the credential.
Verification: constant time, indexed lookup¶
verify_key() uses secrets.compare_digest() rather than ==. A plain
string comparison returns as soon as it hits a mismatched byte, which leaks
timing information an attacker could use to guess a hash one byte at a
time; compare_digest() always takes the same time regardless of where the
strings differ.
Lookup is prefix-indexed, then verified: one indexed query on prefix,
then one constant-time comparison on that single row — not a scan-and-hash
over every key in the table, no matter how many keys exist.
An unrecognized prefix and a recognized prefix with a wrong/tampered secret
produce the exact same rejection ("Invalid API key.", a 401).
Distinguishing "this prefix doesn't exist" from "this prefix exists but the
secret is wrong" in the response would let an attacker enumerate which
prefixes are real. (The two paths aren't timing-identical -- a miss skips the
hash -- but the prefix isn't a secret, so that only reveals whether a
public identifier exists.)
Lifecycle: revocation, expiration, rotation¶
revoke() sets the same is_active flag authentication already checks —
there's no second enforcement path that could fall out of sync with it, and
revoked_at/revoked_reason are audit metadata only. A caller who doesn't
hold a valid secret always gets the same "Invalid API key." and learns
nothing about a key's history. Once the secret has verified, the DRF
backend does say "This API key has been deactivated." or "has expired."
(Django Ninja just returns 401) -- the holder of a valid key isn't the
threat model, and the message saves them a support ticket.
revoke() is idempotent, so a repeat can't overwrite the first revocation's
timestamp and reason. rotate() refuses to run on an already-inactive or
expired key, re-reads the row under a SELECT ... FOR UPDATE lock, and
wraps issuing the replacement + revoking the original in one
transaction.atomic() block -- so a rotation can't half-complete, and two
concurrent (or stale-instance) rotations can't both succeed. (Row locks are
a no-op on SQLite; there, only the re-read applies.) Full detail:
Key lifecycle.
Policies are enforced during authentication¶
The checks that decide whether a valid key may be used at all — active,
expiry, allowed environment, allowed_ips, and the tenant hook — run inside
authenticate_key(), before any view or permission class, and identically
for DRF and Ninja (a parity test suite runs the same requests through both).
So there is no way to write an endpoint that accepts a valid key yet skips
its IP allowlist, which was possible before 0.5 when that check lived in an
opt-in permission class. Scopes (a property of the view) and rate limits (a
DRF/Ninja throttle) are the two things you still have to apply.
Input is shape-checked before it reaches the database: printable, no
whitespace or control characters, at most 128 characters, prefix.secret
with a prefix that fits the column.
Every rejection is logged and sent on a signal with its precise reason (see Signals and logging); the secret, and any prefix that didn't match a real key, are never logged.
IP restrictions: don't trust a spoofable header by default¶
get_client_ip() reads REMOTE_ADDR — the actual TCP connection's source
address — unless TENANT_API_KEY_TRUSTED_PROXY_HEADER explicitly opts into
trusting a specific header instead. This is unset by default on purpose:
X-Forwarded-For and headers like it are ordinary request headers that any
client can set to whatever they want. Turning the setting on without an
actual trusted reverse proxy stripping client-supplied copies of that
header first would let any caller claim their own "IP" and walk straight
through an allowed_ips restriction. See
IP restrictions for the exact
mechanics, including TENANT_API_KEY_TRUSTED_PROXY_COUNT for more than
one trusted proxy.
Rate limiting is not brute-force protection¶
rate_limit caps how often a valid, already-resolved key can be used —
check_rate_limit() runs after verify_key() has already succeeded. It
says nothing about how many wrong keys someone can try against your
authentication endpoint. Pair TenantAPIKeyAuthentication/TenantAPIKeyAuth
with a DRF throttle class scoped to unauthenticated requests, or your
reverse proxy / WAF, if guessing protection at the authentication step
itself matters for your deployment. See
SECURITY.md's scope section
for what's explicitly out of scope for this project.
Rate-limit state also can't leak between keys or tenants — see Rate limiting: tenant isolation — and its default backend's consistency is bounded by whichever Django cache backend it's pointed at; see Rate limiting: how the default backend counts requests for the concurrency and multi-process detail, and Rate limiting: when the cache is unreachable for the deliberate fail-open when the cache itself can't be reached.
Tenant isolation¶
A key resolves to exactly one tenant, in the same query that verifies it —
there's no code path where authenticating with tenant A's key attaches
tenant B's request.tenant. What this package does not do: filter your
other querysets for you. request.tenant being set correctly is the
guarantee; applying it to every other query in your view is still your
application's responsibility — see
tests/test_tenant_isolation.py
for the pattern (get_object_or_404(Model, pk=pk, tenant=request.tenant))
and a worked example of what happens when a view forgets it. See also
Multi-tenancy: what isolation the package gives you, and what it doesn't.
Transport and handling — outside this package's control, but worth stating¶
- HTTPS, always. A key hashed correctly at rest is still a plaintext bearer credential in transit. TLS protects it on the wire; nothing about hashing at rest substitutes for that.
- Headers, not query strings. A key in a URL ends up in server access
logs, browser history, and
Refererheaders sent to third parties.Authorization: Api-Key <key>keeps it out of all three — this is the scheme bothTenantAPIKeyAuthenticationandTenantAPIKeyAuthparse. - Never log the raw key. Log the
prefixif you need to identify which key was involved in an incident — it's designed to be safe to log; the secret half never should be.
What this library does not protect against¶
Stated plainly, matching SECURITY.md's scope section:
- Brute-force guessing against the authentication endpoint itself — see "Rate limiting is not brute-force protection" above.
- How a consuming project configures
TENANT_API_KEY_MODEL, stores keys client-side, or transmits them elsewhere in the stack (e.g. accidentally logging theAuthorizationheader in middleware you wrote). - Automatic queryset filtering by tenant — see "Tenant isolation" above.
- Verifying your proxy topology. The client IP is the
TENANT_API_KEY_TRUSTED_PROXY_COUNT-th entry from the right of the trusted header; that's only right if that many trusted proxies really sit in front of Django — see IP restrictions. - Rate-limit accuracy beyond what your cache provides (per-process on
LocMemCache, lossy on DatabaseCache/FileBasedCache; a DummyCache is
refused outright rather than silently enforcing nothing) — see
Rate limiting.
If the cache is unreachable at request time, the limit fails open
(request allowed, a
WARNINGlogged) rather than failing the request — see Rate limiting: when the cache is unreachable.
Reporting a vulnerability¶
Don't open a public issue. Use GitHub's private reporting form: https://github.com/stackadnan/django-tenant-apikeys/security/advisories/new. Full policy: SECURITY.md.
Next¶
- Testing — how the package's own test suite proves every rejection path, not just the happy path.
- Authentication — the full mechanics referenced throughout this page.