[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an API Key Management System

Build an API Key Management System

Build an API key management system with scoped permissions, usage tracking, automatic rotation, rate limiting per key, and revocation for multi-tenant APIs.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Jade leads platform engineering at a 25-person API company serving 500 customers. API keys are stored in plaintext in the database. All keys have the same permissions — a read-only integration gets full write access. When a customer's key leaks, the only option is to generate a new one, breaking their integration. There's no way to track which key made which request, and no per-key rate limiting. A single customer's runaway script can saturate the API for everyone.

Step 1: Build the Key Management Engine

typescript
// src/auth/api-keys.ts — API key management with scoped permissions and usage tracking
import { pool } from "../db";
import { Redis } from "ioredis";
import { createHash, randomBytes, scryptSync } from "node:crypto";

const redis = new Redis(process.env.REDIS_URL!);

interface APIKey {
  id: string;
  prefix: string;           // first 8 chars shown to user (e.g., "sk_live_a1b2c3d4")
  hashedKey: string;        // scrypt hash stored in DB
  name: string;
  organizationId: string;
  scopes: string[];         // ["read:users", "write:orders", "admin:billing"]
  rateLimit: { requests: number; windowSeconds: number };
  expiresAt: string | null;
  lastUsedAt: string | null;
  lastUsedIp: string | null;
  status: "active" | "revoked" | "expired";
  createdBy: string;
  createdAt: string;
}

// Generate new API key with scoped permissions
export async function createKey(params: {
  name: string;
  organizationId: string;
  scopes: string[];
  rateLimit?: { requests: number; windowSeconds: number };
  expiresInDays?: number;
  createdBy: string;
}): Promise<{ key: string; record: APIKey }> {
  const id = `key-${randomBytes(6).toString("hex")}`;

  // Generate key: sk_live_ + 32 random bytes as hex
  const rawKey = randomBytes(32).toString("hex");
  const prefix = rawKey.slice(0, 8);
  const fullKey = `sk_live_${rawKey}`;

  // Hash key with scrypt for storage (never store plaintext)
  const salt = randomBytes(16);
  const hashedKey = scryptSync(fullKey, salt, 64).toString("hex") + ":" + salt.toString("hex");

  // Also store a fast lookup hash (SHA-256) for request-time validation
  const lookupHash = createHash("sha256").update(fullKey).digest("hex");

  const record: APIKey = {
    id, prefix, hashedKey, name: params.name,
    organizationId: params.organizationId,
    scopes: params.scopes,
    rateLimit: params.rateLimit || { requests: 1000, windowSeconds: 60 },
    expiresAt: params.expiresInDays
      ? new Date(Date.now() + params.expiresInDays * 86400000).toISOString()
      : null,
    lastUsedAt: null, lastUsedIp: null,
    status: "active", createdBy: params.createdBy,
    createdAt: new Date().toISOString(),
  };

  await pool.query(
    `INSERT INTO api_keys (id, prefix, hashed_key, lookup_hash, name, organization_id, scopes, rate_limit, expires_at, status, created_by, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, NOW())`,
    [id, prefix, hashedKey, lookupHash, params.name, params.organizationId,
     JSON.stringify(params.scopes), JSON.stringify(record.rateLimit),
     record.expiresAt, params.createdBy]
  );

  // Cache key metadata for fast validation
  await redis.setex(`apikey:${lookupHash}`, 300, JSON.stringify(record));

  return { key: fullKey, record };  // Return plaintext key ONCE — never stored
}

// Validate API key from request
export async function validateKey(rawKey: string): Promise<{
  valid: boolean;
  key?: APIKey;
  reason?: string;
}> {
  const lookupHash = createHash("sha256").update(rawKey).digest("hex");

  // Check Redis cache first
  let record: APIKey | null = null;
  const cached = await redis.get(`apikey:${lookupHash}`);
  if (cached) {
    record = JSON.parse(cached);
  } else {
    const { rows: [row] } = await pool.query(
      "SELECT * FROM api_keys WHERE lookup_hash = $1",
      [lookupHash]
    );
    if (row) {
      record = {
        ...row,
        scopes: JSON.parse(row.scopes),
        rateLimit: JSON.parse(row.rate_limit),
      };
      await redis.setex(`apikey:${lookupHash}`, 300, JSON.stringify(record));
    }
  }

  if (!record) return { valid: false, reason: "Invalid API key" };
  if (record.status === "revoked") return { valid: false, reason: "Key revoked" };
  if (record.expiresAt && new Date(record.expiresAt) < new Date()) {
    return { valid: false, reason: "Key expired" };
  }

  // Rate limiting per key
  const rateLimited = await checkKeyRateLimit(record);
  if (rateLimited) return { valid: false, reason: "Rate limit exceeded" };

  // Update last used (async, non-blocking)
  updateLastUsed(record.id, "0.0.0.0").catch(() => {});

  return { valid: true, key: record };
}

// Check if request has required scope
export function hasScope(key: APIKey, requiredScope: string): boolean {
  // Support wildcards: "admin:*" matches "admin:billing"
  return key.scopes.some((scope) => {
    if (scope === requiredScope) return true;
    if (scope.endsWith(":*")) {
      const prefix = scope.slice(0, -1);  // "admin:"
      return requiredScope.startsWith(prefix);
    }
    return false;
  });
}

// Revoke key immediately
export async function revokeKey(keyId: string, reason: string): Promise<void> {
  await pool.query(
    "UPDATE api_keys SET status = 'revoked' WHERE id = $1",
    [keyId]
  );

  // Invalidate cache
  const { rows: [row] } = await pool.query("SELECT lookup_hash FROM api_keys WHERE id = $1", [keyId]);
  if (row) await redis.del(`apikey:${row.lookup_hash}`);
}

// Rotate key (create new, schedule old revocation)
export async function rotateKey(keyId: string, gracePeriodHours: number = 24): Promise<{ newKey: string }> {
  const { rows: [old] } = await pool.query("SELECT * FROM api_keys WHERE id = $1", [keyId]);
  if (!old) throw new Error("Key not found");

  // Create replacement key with same scopes
  const { key: newKey } = await createKey({
    name: `${old.name} (rotated)`,
    organizationId: old.organization_id,
    scopes: JSON.parse(old.scopes),
    rateLimit: JSON.parse(old.rate_limit),
    createdBy: old.created_by,
  });

  // Schedule old key revocation after grace period
  const revokeAt = new Date(Date.now() + gracePeriodHours * 3600000).toISOString();
  await redis.setex(`apikey:revoke:${keyId}`, gracePeriodHours * 3600, "pending");

  return { newKey };
}

// Per-key rate limiting using sliding window
async function checkKeyRateLimit(key: APIKey): Promise<boolean> {
  const windowKey = `apikey:rl:${key.id}:${Math.floor(Date.now() / (key.rateLimit.windowSeconds * 1000))}`;
  const count = await redis.incr(windowKey);
  await redis.expire(windowKey, key.rateLimit.windowSeconds * 2);
  return count > key.rateLimit.requests;
}

async function updateLastUsed(keyId: string, ip: string): Promise<void> {
  // Batch updates every 60s to reduce DB writes
  const batchKey = `apikey:usage:${keyId}`;
  await redis.set(batchKey, JSON.stringify({ ip, at: new Date().toISOString() }), "EX", 60, "NX");
}

// Usage analytics per key
export async function getKeyUsage(keyId: string, days: number = 30): Promise<{
  totalRequests: number;
  dailyRequests: Record<string, number>;
  scopeBreakdown: Record<string, number>;
}> {
  const { rows } = await pool.query(
    `SELECT DATE(created_at) as day, COUNT(*) as count
     FROM api_request_logs WHERE key_id = $1 AND created_at > NOW() - $2 * INTERVAL '1 day'
     GROUP BY DATE(created_at) ORDER BY day`,
    [keyId, days]
  );

  const dailyRequests: Record<string, number> = {};
  let total = 0;
  for (const row of rows) {
    dailyRequests[row.day.toISOString().slice(0, 10)] = parseInt(row.count);
    total += parseInt(row.count);
  }

  return { totalRequests: total, dailyRequests, scopeBreakdown: {} };
}

// Middleware for Hono
export function apiKeyMiddleware(requiredScope?: string) {
  return async (c: any, next: any) => {
    const authHeader = c.req.header("Authorization");
    const apiKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : c.req.header("X-API-Key");

    if (!apiKey) return c.json({ error: "API key required" }, 401);

    const result = await validateKey(apiKey);
    if (!result.valid) return c.json({ error: result.reason }, result.reason === "Rate limit exceeded" ? 429 : 401);

    if (requiredScope && !hasScope(result.key!, requiredScope)) {
      return c.json({ error: `Missing scope: ${requiredScope}` }, 403);
    }

    c.set("apiKey", result.key);
    c.set("organizationId", result.key!.organizationId);
    await next();
  };
}

Results

  • Keys never stored in plaintext — scrypt hash in DB + SHA-256 lookup hash for fast validation; database leak doesn't expose keys
  • Scoped permissions — read-only integrations get ["read:users"]; full-access keys get ["admin:*"]; leaked read key can't modify data
  • Per-key rate limiting — customer's runaway script hits their 1000 req/min limit without affecting other customers; sliding window in Redis
  • Zero-downtime rotation — new key generated with same scopes; old key stays active during 24h grace period; customer migrates without outage
  • Usage analytics per key — dashboard shows which key made how many requests; identifies unused keys for cleanup and heavy users for upsell

Related use cases