Multi-Tenant API Key Authentication Design¶
Issue: #2 Date: 2026-05-04
Goal¶
Replace the single shared API key with per-user API keys stored in Supabase. This enables per-user link ownership, and unblocks rate limiting (#3), usage tracking (#6), pricing tiers (#10), and self-service sign-up (#12).
Data Model¶
New api_keys table¶
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
label TEXT,
tier TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash);
key_hash: SHA-256 of the full key. Never store plaintext.key_prefix: First 8 chars after theusnap_k_prefix, for display/identification.tier:'free','pro', etc. Defaults to'free'.revoked_at: NULL = active. Set = revoked (soft delete).
Alter links table¶
Nullable — existing links created via master key have api_key_id = NULL.
Key Format¶
usnap_k_ + 32 random alphanumeric characters.
Example: usnap_k_a3Bf9x2Kd7QmN5vR8pL1wY4tH6jF0c
Total length: 40 characters. The full key is shown once at creation and never again.
Authentication Flow¶
When a request includes X-API-Key:
- Check if key matches
API_KEYenv var (master key). If yes, allow.api_key_id = None. - Hash the key with SHA-256. Query
api_keysfor matchingkey_hashwhererevoked_at IS NULL. - If found, allow. Return the row's
idasapi_key_id. - If neither, return 401.
Master key is a fast-path bypass with no DB query. DB key lookup is indexed via UNIQUE on key_hash.
The redirect endpoint (GET /{short_id}) stays unauthenticated.
New Endpoints¶
All key management endpoints are tagged "API Keys" in OpenAPI.
POST /api-keys — Create a key¶
- Auth: Master key only.
- Body:
{ "label": "My ListHook key" }(label is optional) - Returns:
{ "key": "usnap_k_...", "id": "...", "key_prefix": "a3Bf9x2K", "label": "...", "tier": "free", "created_at": "..." } - The full key is returned once in this response and never stored or retrievable again.
GET /api-keys — List all keys¶
- Auth: Master key only.
- Returns: Array of
{ "id", "key_prefix", "label", "tier", "created_at", "revoked_at" }. - Never returns hashes or full keys.
DELETE /api-keys/{key_id} — Revoke a key¶
- Auth: Master key OR the key being revoked (self-service).
- Behavior: Sets
revoked_at = NOW(). Soft delete — preserves link ownership history. - Returns: 204 No Content.
POST /api-keys/{key_id}/rotate — Rotate a key¶
- Auth: Master key OR the key being rotated (self-service).
- Behavior: Generates a new key, updates
key_hashandkey_prefixon the same row. Old key stops working immediately. All links stay associated with the sameapi_key_id. - Returns:
{ "key": "usnap_k_...", "key_prefix": "..." }(new key, shown once).
Changes to Existing Code¶
- Auth helper: Extract an
authenticate_request(x_api_key) -> Optional[UUID]function that returnsNonefor master key or theapi_key_idfor DB keys. Raises 401 if invalid. POST /shorten: Call auth helper. Passapi_key_idinto the link insert.GET /{short_id}: No changes.GET /{short_id}/qr: No changes.- Webhook/hit logic: No changes.
What This Unblocks¶
- #3 Rate limiting: Query links/hits by
api_key_idto enforce per-key limits. - #6 Usage tracking: Count links/hits/webhook deliveries per
api_key_id. - #10 Pricing tiers:
tiercolumn onapi_keysdrives feature gates. - #12 Sign-up flow: Future endpoint creates keys for new users instead of requiring master key.