API Keys

API Keys

Per-user API key management. Keys use a wk_ prefix and are stored hashed: only SHA-256(key) plus a masked preview live in the DB. The raw key is returned once at creation and is unrecoverable afterwards , all subsequent reads return the stored preview.

Hashing means a server-side compromise or a leaked backup exposes nothing usable at the API surface. The hash matters regardless of DB architecture , per-user SQLite limited cross-user blast radius in the base template, and app-level user_id scoping does the equivalent job in this fork's shared Postgres, but neither protects against DB/filesystem-level access , the hash does. The user's own data export also redacts key_hash/key (see dumpUserDbTables's REDACTED_COLUMNS in src/features/account/server/account.server.ts); key material never leaves the server after creation.

Architecture

  • Keys stored in the shared apiKeys pgTable (src/lib/db/schema.ts), scoped by user_id
  • Columns: key_hash (SHA-256 hex, unique) + key_preview (masked, for the UI)
  • Hashing: hashKey() in api-keys.server.ts uses node:crypto's createHash('sha256') , the base template's Bun.CryptoHasher was swapped for this during the Postgres port since it isn't portable outside Bun (doesn't run under workerd/Cloudflare Workers)
  • Raw key returned only from createApiKeyFn response , never again after that
  • UI always shows the stored preview: wk_••••••••...abcd
  • touchApiKey updates last_used_at , call it from your auth middleware on each authenticated request. Stays lock-free (no withUserTransaction) per the single-row-UPDATE-by-PK exception , but is scoped by userId too, with a cross-tenant regression test, unlike a bare PK-only update
  • Verify a presented key: query by key_hash with hashKey(presented) from api-keys.server.ts

Key files

FilePurpose
src/features/api-keys/server/api-keys.server.tsKey generation, hashing, masking, DB operations
src/features/api-keys/server/api-keys.mutations.tsServer functions: create, revoke, delete, touch
src/features/api-keys/server/api-keys.queries.tsServer function: list (masked)
src/features/api-keys/index.tsPublic barrel

Server functions

FunctionMethodDescription
getApiKeysFnGETList all keys (masked previews)
createApiKeyFnPOSTCreate key, returns raw key once
revokeApiKeyFnPOSTSet status to revoked
deleteApiKeyFnPOSTHard delete
touchApiKeyFnPOSTUpdate last_used_at (no transaction, no event log , single-row-UPDATE-by-PK exception, own rate limiter)

Usage

import { createApiKeyFn, getApiKeysFn, revokeApiKeyFn, touchApiKeyFn } from '@/features/api-keys';

// Create , show rawKey to user immediately, it won't be available again
const result = await createApiKeyFn({ data: { name: 'My app' } });
if (result.ok) {
  console.log(result.data.rawKey); // show once
}

// Track usage from your auth middleware
await touchApiKeyFn({ data: { id: keyId } });

Key format

Keys are generated with crypto.getRandomValues and prefixed with wk_:

wk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4

The prefix makes keys identifiable in logs and config files. Storage is SHA-256(key) via hashKey() in api-keys.server.ts; the raw form exists only in the createApiKeyFn response.

Statuses

StatusMeaning
activeKey works
revokedKey rejected by middleware , row still exists for audit

Revoked keys remain in the DB so the audit trail is preserved. Delete permanently removes the row.

Plan limits

createApiKeyFn enforces maxApiKeys from config.stripe.limits via checkEntitlement (defaults: free 3, solo 10, pro/team unlimited; -1 = unlimited). Subscription plan is read before withUserTransaction (read-only gate); the key count is taken inside the transaction, on a fresh read. Returns ERROR_CODES.PLAN_LIMIT_EXCEEDED when at cap.