Skip to content

Workspace API Key Management

Migrated from root technical docs.

This document describes the workspace-scoped API key system that integrates with Better Auth and provides public API authentication.

Workspace administrators can create and manage API keys for authenticating requests to the public API (api.legaciti.org). Each workspace maintains its own set of API keys, and keys are tied to a single domain per the system design.

  1. Better Auth API Key Plugin (apiKey plugin in auth config)
    • Manages API key creation, storage, and retrieval
    • Stores keys in the apikey table with plugin-managed fields
  • Configuration ID: worker-consumer-api-consumer (identifies public API keys)
    • References: organization (workspace ID as reference_id)
  1. Dashboard Server Functions (apps/dashboard/src/server/functions/workspace-settings/index.ts)

    • listWorkspaceApiKeys(): Retrieve all API keys for a workspace
    • createWorkspaceApiKey(): Generate new API key with name, domain, optional rate limits
    • deleteWorkspaceApiKey(): Hard delete an API key
  2. Public API Worker (workers/consumer-api/src/index.ts)

    • Validates incoming API keys against Better Auth apikey table
    • Enforces domain restrictions
    • Applies rate limiting (plugin-native remaining counter)
    • Checks workspace active/termination status
  3. Dashboard Feature Layer (apps/dashboard/src/features/workspace-settings/)

    • React Query hooks for fetching, creating, deleting API keys
    • Type-safe wrappers around server functions
    • UI components in dashboard (ApiKeyList, GenerateApiKeyDialog)
ColumnTypeNotes
idTEXT PRIMARY KEYUnique key identifier
config_idTEXTConfig ID; worker-consumer-api-consumer for public API
nameTEXTHuman-readable name (optional)
keyTEXTPlaintext API key (Better Auth stores plaintext)
reference_idTEXTWorkspace/organization ID
enabledINTEGER0 or 1; can be disabled without deletion
expires_atINTEGERExpiration timestamp (milliseconds); NULL = never expires
metadataTEXTJSON metadata; stores { domain: string }
rate_limit_enabledINTEGER0 or 1
rate_limit_maxINTEGERMax requests per window
rate_limit_time_windowINTEGERTime window in seconds
remainingINTEGERRequests remaining in current window
last_requestINTEGERTimestamp of last request
created_atINTEGERCreation timestamp
updated_atINTEGERLast update timestamp
  • idx_apikey_key (UNIQUE): Fast plaintext key lookup
  • idx_apikey_reference_id: Fast workspace lookup
  • idx_apikey_config_id: Fast config filtering
  1. Navigate to Workspace Settings → API Keys
  2. Click Generate New Key
  3. Enter:
    • Name: Human-readable identifier (e.g., “Production Client”)
    • Domain: Single domain this key can be used from
  4. Optionally set rate limits:
    • Max Requests: Limit per time window
    • Time Window: Duration in seconds
  5. Click Create
  6. Important: Copy the plaintext key immediately. It will not be displayed again.

Call the public API with the X-API-Key header:

Terminal window
curl -H "X-API-Key: lcg_prod_abc123xyz789" \
https://api.legaciti.org/v1/publications
  1. Navigate to Workspace Settings → API Keys
  2. Click Delete (trash icon) on the key you want to remove
  3. Confirm deletion in the dialog
  4. The key is immediately hard-deleted and cannot be recovered

If rate limiting is enabled on a key:

  • The remaining counter decrements with each request
  • When remaining reaches 0, requests are rejected with 429 status
  • The counter resets on the next window boundary (if configured for windows > 1)

If rate limiting is disabled:

  • Requests have no per-key limit (subject to global IP-based rate limiting)
  • Only workspace_admin or superadmin can manage API keys for a workspace
  • Regular workspace members cannot create, list, or delete keys
  • Plaintext keys are displayed once only after creation
  • Keys are never shown again in the dashboard
  • Always copy the key immediately to a secure location (e.g., environment variables)
  • There is no “show/reveal” button in the dashboard
  • Each key is restricted to a single domain (single-domain contract)
  • Domain stored in metadata JSON: { domain: "api.example.com" }
  • Public API worker enforces domain match for future use
  • API keys are only active if the workspace has status active
  • Keys are blocked if workspace has been archived or is past termination date
  • Use workspace settings to manage archive/termination status

Method: GET Authorization: workspace_admin or superadmin

Response:

{
keys: [
{
id: string;
name: string | null;
start: string | null; // "lcg"
prefix: string | null; // e.g., "prod"
enabled: boolean;
created_at: number; // milliseconds
expires_at: number | null; // milliseconds
last_request: number | null; // milliseconds
domain: string | null; // extracted from metadata
}
]
}

Method: POST Authorization: workspace_admin or superadmin

Request:

{
name: string; // Min 1, max 120 chars
domain: string; // Min 1, max 255 chars
expiresInSeconds?: number; // Optional
rateLimitMax?: number; // Optional
rateLimitTimeWindow?: number; // Optional, in seconds
}

Response:

{
key: string; // Plaintext key (one-time display)
api_key: string; // Same as key
api_key_id: string; // Database ID for reference
name: string;
domain: string; // Normalized
created_at: number; // milliseconds
expires_at: number | null; // milliseconds
start: string | null; // e.g., "lcg"
prefix: string | null; // e.g., "prod_key"
}

Method: POST Authorization: workspace_admin or superadmin

Request:

{
id: string; // API key ID to delete
}

Response:

{
error?: string; // If authorization denied
}

Side Effect: Key is hard-deleted immediately. No recovery possible.

When a request arrives at /v1/publications or /api/ingest with X-API-Key header:

  1. Lookup: Query apikey table for config_id='worker-consumer-api-consumer' AND key=?
  2. Enable Check: Verify enabled=1
  3. Expiration Check: Verify expires_at IS NULL OR expires_at > now()
  4. Workspace Check: If reference_id present, verify workspace is active + not past termination
  5. Rate Limit Check: If rate_limit_enabled=1 and remaining <= 0, return 429
  6. Update: Set last_request=now() and decrement remaining (if rate limit enabled)
  7. Allow: Request proceeds

All checks fail with appropriate HTTP status codes (401, 403, 429).

Run unit tests for server functions and UI components:

Terminal window
pnpm --filter @apps/dashboard test -- api-keys.test.ts
pnpm --filter @apps/dashboard test -- api-keys-components.test.tsx

Run worker integration tests:

Terminal window
pnpm --filter @workers/consumer-api test -- api-key-integration.test.ts

End-to-end tests exercise the full flow: create a key, use it to call the public API, verify rate limiting, delete the key.

Tests are located in tests/features/ and run via the custom Node harness.

The legacy system used hashed keys in a separate api_keys table. The new system:

  • Uses Better Auth plugin for key management
  • Stores plaintext keys (plugin design)
  • Adds domain-level restriction
  • Implements plugin-native rate limiting (instead of request logs)
  • Removes per-key request logging

During rollout, both systems may be active:

  • New keys are created via Better Auth plugin
  • Existing keys in legacy api_keys table are still functional
  • After rollout complete, legacy api_keys table can be deprecated
  • Existing hashed keys cannot be migrated (plaintext unavailable)
  • Consumers must rotate keys: generate new keys in dashboard, update clients
  • Legacy key operations (via old API) continue until cutoff date
  • Provide migration window (e.g., 60 days) before disabling legacy keys
  • Key rotation (automatic expiration + new key generation)
  • Per-domain analytics (requests per domain)
  • Scope-based permissions (e.g., read-only publications vs. ingest)
  • Multiple domains per key
  • API key webhooks (events when key is used, rotated, deleted)