Skip to content

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 Referer headers sent to third parties. Authorization: Api-Key <key> keeps it out of all three — this is the scheme both TenantAPIKeyAuthentication and TenantAPIKeyAuth parse.
  • Never log the raw key. Log the prefix if 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 the Authorization header 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 WARNING logged) 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.