Skip to content

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 the usnap_k_ prefix, for display/identification.
  • tier: 'free', 'pro', etc. Defaults to 'free'.
  • revoked_at: NULL = active. Set = revoked (soft delete).
ALTER TABLE links ADD COLUMN api_key_id UUID REFERENCES api_keys(id);

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:

  1. Check if key matches API_KEY env var (master key). If yes, allow. api_key_id = None.
  2. Hash the key with SHA-256. Query api_keys for matching key_hash where revoked_at IS NULL.
  3. If found, allow. Return the row's id as api_key_id.
  4. 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_hash and key_prefix on the same row. Old key stops working immediately. All links stay associated with the same api_key_id.
  • Returns: { "key": "usnap_k_...", "key_prefix": "..." } (new key, shown once).

Changes to Existing Code

  1. Auth helper: Extract an authenticate_request(x_api_key) -> Optional[UUID] function that returns None for master key or the api_key_id for DB keys. Raises 401 if invalid.
  2. POST /shorten: Call auth helper. Pass api_key_id into the link insert.
  3. GET /{short_id}: No changes.
  4. GET /{short_id}/qr: No changes.
  5. Webhook/hit logic: No changes.

What This Unblocks

  • #3 Rate limiting: Query links/hits by api_key_id to enforce per-key limits.
  • #6 Usage tracking: Count links/hits/webhook deliveries per api_key_id.
  • #10 Pricing tiers: tier column on api_keys drives feature gates.
  • #12 Sign-up flow: Future endpoint creates keys for new users instead of requiring master key.