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
apiKeyspgTable (src/lib/db/schema.ts), scoped byuser_id - Columns:
key_hash(SHA-256 hex, unique) +key_preview(masked, for the UI) - Hashing:
hashKey()inapi-keys.server.tsusesnode:crypto'screateHash('sha256'), the base template'sBun.CryptoHasherwas swapped for this during the Postgres port since it isn't portable outside Bun (doesn't run underworkerd/Cloudflare Workers) - Raw key returned only from
createApiKeyFnresponse , never again after that - UI always shows the stored preview:
wk_••••••••...abcd touchApiKeyupdateslast_used_at, call it from your auth middleware on each authenticated request. Stays lock-free (nowithUserTransaction) per the single-row-UPDATE-by-PK exception , but is scoped byuserIdtoo, with a cross-tenant regression test, unlike a bare PK-only update- Verify a presented key: query by
key_hashwithhashKey(presented)fromapi-keys.server.ts
Key files
| File | Purpose |
|---|---|
src/features/api-keys/server/api-keys.server.ts | Key generation, hashing, masking, DB operations |
src/features/api-keys/server/api-keys.mutations.ts | Server functions: create, revoke, delete, touch |
src/features/api-keys/server/api-keys.queries.ts | Server function: list (masked) |
src/features/api-keys/index.ts | Public barrel |
Server functions
| Function | Method | Description |
|---|---|---|
getApiKeysFn | GET | List all keys (masked previews) |
createApiKeyFn | POST | Create key, returns raw key once |
revokeApiKeyFn | POST | Set status to revoked |
deleteApiKeyFn | POST | Hard delete |
touchApiKeyFn | POST | Update 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
| Status | Meaning |
|---|---|
active | Key works |
revoked | Key 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.