Core Concepts
Key format, claims, signing, and the validation pipeline.
Core Concepts
Key format
Every apikee key is a string with three parts separated by an underscore and a dot:
apikee_<payload>.<signature>| Part | Content | Purpose |
|---|---|---|
apikee_ | Literal prefix | Instant identification, secret scanning in git/CI logs |
<payload> | base62(JSON(claims)) | Embeds all metadata — no DB needed to read expiry or scopes |
<signature> | base62(HMAC-SHA256(secret, payload_bytes)) | Proves the key was issued by a holder of the secret |
Base62 encoding (0–9A–Za–z) keeps keys URL-safe without padding characters.
Claims object
{
"id": "01J4KX8M...", // ULID-style sortable unique ID
"tid": "acme-corp", // tenant / owner identifier
"scp": ["read", "write"], // scopes / permissions
"exp": 1750000000, // unix expiry timestamp (optional)
"nbf": 1740000000, // not-valid-before timestamp (optional)
"env": "production", // environment tag
"meta": { "plan": "pro" } // arbitrary user-defined data
}Because the expiry (exp) and scopes (scp) are embedded in the key itself, validation never requires a database lookup. The entire check is CPU-only.
Signing
Keys are signed with HMAC-SHA256 using your signing secret. This is a symmetric scheme — the same secret is used to sign and verify.
Advantages over asymmetric signing (like RS256 JWT):
- No key pair management
- Faster — HMAC-SHA256 is ~10× faster than RSA-256 verify
- Simpler rotation — add new secret at index 0, old keys still validate
Validation always uses constant-time comparison (hmac.compare_digest / CryptographicOperations.FixedTimeEquals) to prevent timing oracle attacks. Never compare signatures with ==.
Validation pipeline
When a request arrives with an x-api-key header, the middleware runs these steps in order:
Step 1 Split on "." → payload_b62, sig_b62
Step 2 Decode base62 → payload bytes
Step 3 HMAC-SHA256 recompute → expected signature
Step 4 Constant-time compare → valid or reject
Step 5 Deserialise JSON → check exp, nbf, scopes
Step 6 Redis revocation check → (optional, only if early revoke needed)
Step 7 apikee.dev validation → (optional, server mode only)Steps 1–5 are pure CPU — no I/O, no network, no database. Typical latency: 0.05–0.2ms.
Step 6 is optional. Because expiry is in the key, you only need a revocation store for early invalidation before natural expiry.
Step 7 adds a single encrypted network call to apikee.dev/api/v1 for server-side IP checking, fraud scoring, and rate limiting.
Secret rotation
apikee supports multiple secrets simultaneously to enable zero-downtime rotation:
# Day 1 — single secret
apikee = Apikee(secrets=["secret-v1"])
# Day 30 — add new secret at index 0
# New keys signed with secret-v2, old keys still validate against secret-v1
apikee = Apikee(secrets=["secret-v2", "secret-v1"])
# Day 120 — all v1 keys have expired, remove old secret
apikee = Apikee(secrets=["secret-v2"])The library always signs with secrets[0] and verifies against all secrets in order.
Key IDs
Every key contains a unique id field (ULID-style: timestamp prefix + random suffix, base62-encoded). This ID is used for:
- Revocation — store
idin a Redis set to invalidate before expiry - Audit logging — identify which key was used for a given request
- apikee.dev tracking — correlate requests across your dashboard
Tenant isolation
The tid (tenant) field lets you issue different keys to different customers and have the claims available in every request handler without any additional lookup:
# Issue per-customer keys
key_acme = apikee.create("acme-corp", scopes=["read"])
key_beta = apikee.create("beta-inc", scopes=["read", "write"])
# In your handler — claims.tenant tells you exactly who is calling
claims = apikee.verify(request.headers["x-api-key"])
data = db.query("SELECT * FROM items WHERE tenant = ?", claims.tenant)
