[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build a Developer Portal with API Key Management

Build a Developer Portal with API Key Management

Build a self-service developer portal where API consumers create accounts, generate scoped API keys, monitor usage, and manage rate limits — reducing support tickets by 80%.

#web-framework#edge#cloudflare#bun#deno
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 6 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

postgresql

v1.0.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.

87/100 quality
1.53× impact
SAFE
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
>

nextjs

v1.0.0

Assists with building production-grade React applications using Next.js. Use when working with the App Router, Server Components, Server Actions, Middleware, or deploying to Vercel or self-hosted environments. Trigger words: nextjs, next.js, app router, server components, server actions, react framework, ssr, isr.

93/100 quality
1.16× impact
SAFE
View skill
$

The Problem

Ravi's team runs a data enrichment API at a 30-person B2B startup. They have 200+ API consumers, but key management is manual: developers email support to get keys, there's no usage dashboard, and revoking a compromised key means grepping through a config file and redeploying. Support handles 15 key-related tickets per week. After a customer accidentally committed their key to a public GitHub repo, the team spent 4 hours on emergency rotation. A self-service portal would eliminate this overhead and give customers the visibility they need.

Step 1: Design the Key Data Model

API keys need to support scoping (which endpoints a key can access), rate limiting (per key, not just per account), and instant revocation without requiring server restarts.

typescript
// src/db/schema.ts — Database schema for API key management
import { z } from "zod";

// Key creation request validation
export const CreateKeySchema = z.object({
  name: z.string().min(1).max(100),          // human-readable label
  scopes: z.array(z.string()).min(1),         // e.g., ["enrichment:read", "batch:write"]
  rateLimitPerMinute: z.number().int().min(1).max(10000).default(60),
  expiresAt: z.string().datetime().optional(), // optional expiration
  allowedIPs: z.array(z.string().ip()).optional(), // optional IP allowlist
});

// SQL migrations for the key tables
export const migrations = `
  CREATE TABLE api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL REFERENCES accounts(id),
    name VARCHAR(100) NOT NULL,
    key_prefix VARCHAR(8) NOT NULL,        -- first 8 chars shown in UI (sk_live_abc1...)
    key_hash VARCHAR(64) NOT NULL,          -- SHA-256 hash of full key
    scopes TEXT[] NOT NULL,
    rate_limit_per_minute INT NOT NULL DEFAULT 60,
    allowed_ips INET[],
    expires_at TIMESTAMPTZ,
    revoked_at TIMESTAMPTZ,                 -- null = active, set = revoked
    last_used_at TIMESTAMPTZ,
    total_requests BIGINT DEFAULT 0,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    
    CONSTRAINT unique_key_hash UNIQUE (key_hash)
  );

  -- Index for fast key lookups during request authentication
  CREATE INDEX idx_api_keys_hash ON api_keys (key_hash) WHERE revoked_at IS NULL;
  
  -- Usage tracking: per-key, per-day aggregation
  CREATE TABLE key_usage (
    id BIGSERIAL PRIMARY KEY,
    key_id UUID NOT NULL REFERENCES api_keys(id),
    date DATE NOT NULL DEFAULT CURRENT_DATE,
    endpoint VARCHAR(200) NOT NULL,
    success_count INT DEFAULT 0,
    error_count INT DEFAULT 0,
    total_latency_ms BIGINT DEFAULT 0,      -- for average latency calculation
    
    CONSTRAINT unique_key_date_endpoint UNIQUE (key_id, date, endpoint)
  );
  
  CREATE INDEX idx_key_usage_date ON key_usage (key_id, date);
`;

Step 2: Build Secure Key Generation and Storage

Keys are generated with crypto-safe randomness, shown to the user exactly once, and stored as SHA-256 hashes. The prefix allows identification without exposing the full key.

typescript
// src/services/key-service.ts — Secure API key lifecycle management
import { randomBytes, createHash } from "node:crypto";
import { pool } from "../db";
import { Redis } from "ioredis";

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

const KEY_PREFIX = "sk_live_"; // sk_test_ for sandbox keys

interface KeyCreateResult {
  id: string;
  name: string;
  key: string;          // full key — shown ONCE, never stored
  keyPrefix: string;    // shown in dashboard for identification
  scopes: string[];
  rateLimitPerMinute: number;
  createdAt: Date;
}

export async function createApiKey(
  accountId: string,
  params: {
    name: string;
    scopes: string[];
    rateLimitPerMinute: number;
    expiresAt?: string;
    allowedIPs?: string[];
  }
): Promise<KeyCreateResult> {
  // Generate 32 bytes of randomness → 43 base64url characters
  const secret = randomBytes(32).toString("base64url");
  const fullKey = `${KEY_PREFIX}${secret}`;
  const keyPrefix = fullKey.slice(0, 12); // e.g., "sk_live_abc1"
  const keyHash = createHash("sha256").update(fullKey).digest("hex");

  const { rows } = await pool.query(
    `INSERT INTO api_keys (account_id, name, key_prefix, key_hash, scopes, rate_limit_per_minute, expires_at, allowed_ips)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
     RETURNING id, name, scopes, rate_limit_per_minute, created_at`,
    [
      accountId,
      params.name,
      keyPrefix,
      keyHash,
      params.scopes,
      params.rateLimitPerMinute,
      params.expiresAt || null,
      params.allowedIPs || null,
    ]
  );

  return {
    ...rows[0],
    key: fullKey,       // returned once, never stored in plaintext
    keyPrefix,
  };
}

export async function revokeKey(keyId: string, accountId: string): Promise<boolean> {
  const { rowCount } = await pool.query(
    "UPDATE api_keys SET revoked_at = NOW() WHERE id = $1 AND account_id = $2 AND revoked_at IS NULL",
    [keyId, accountId]
  );

  if (rowCount && rowCount > 0) {
    // Invalidate cached key immediately across all servers
    await redis.publish("key:revoked", keyId);
    // Remove from local verification cache
    await redis.del(`key:${keyId}:valid`);
    return true;
  }
  return false;
}

// Key verification during API requests — called on every request
export async function verifyKey(
  rawKey: string,
  requiredScope: string,
  clientIP: string
): Promise<{ valid: boolean; keyId?: string; accountId?: string; error?: string }> {
  const keyHash = createHash("sha256").update(rawKey).digest("hex");

  // Check Redis cache first (99% of lookups)
  const cached = await redis.get(`keyhash:${keyHash}`);
  if (cached) {
    const keyData = JSON.parse(cached);
    return validateKeyData(keyData, requiredScope, clientIP);
  }

  // Cache miss — query database
  const { rows } = await pool.query(
    `SELECT id, account_id, scopes, rate_limit_per_minute, allowed_ips, expires_at
     FROM api_keys
     WHERE key_hash = $1 AND revoked_at IS NULL`,
    [keyHash]
  );

  if (rows.length === 0) {
    return { valid: false, error: "Invalid API key" };
  }

  const keyData = rows[0];

  // Cache for 5 minutes — balances speed with revocation latency
  await redis.setex(`keyhash:${keyHash}`, 300, JSON.stringify(keyData));

  return validateKeyData(keyData, requiredScope, clientIP);
}

function validateKeyData(
  keyData: any,
  requiredScope: string,
  clientIP: string
): { valid: boolean; keyId?: string; accountId?: string; error?: string } {
  // Check expiration
  if (keyData.expires_at && new Date(keyData.expires_at) < new Date()) {
    return { valid: false, error: "API key expired" };
  }

  // Check IP allowlist
  if (keyData.allowed_ips?.length > 0 && !keyData.allowed_ips.includes(clientIP)) {
    return { valid: false, error: "IP not in allowlist" };
  }

  // Check scope
  if (!keyData.scopes.includes(requiredScope) && !keyData.scopes.includes("*")) {
    return { valid: false, error: `Missing scope: ${requiredScope}` };
  }

  return {
    valid: true,
    keyId: keyData.id,
    accountId: keyData.account_id,
  };
}

Step 3: Add Per-Key Rate Limiting

Rate limits are enforced per key using a Redis sliding window. Each key has its own limit, configurable from the portal.

typescript
// src/middleware/rate-limit.ts — Per-key sliding window rate limiter
import { Redis } from "ioredis";
import { Context, Next } from "hono";

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

export function rateLimitMiddleware() {
  return async (c: Context, next: Next) => {
    const keyId = c.get("keyId");
    const rateLimit = c.get("rateLimit") as number; // set by auth middleware

    const windowKey = `ratelimit:${keyId}`;
    const now = Date.now();
    const windowMs = 60_000; // 1-minute sliding window

    // Lua script for atomic sliding window check
    const result = await redis.eval(
      `
      local key = KEYS[1]
      local now = tonumber(ARGV[1])
      local window = tonumber(ARGV[2])
      local limit = tonumber(ARGV[3])
      
      -- Remove entries outside the window
      redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
      
      -- Count current requests in window
      local count = redis.call('ZCARD', key)
      
      if count < limit then
        -- Add this request
        redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
        redis.call('PEXPIRE', key, window)
        return {count + 1, limit, 0}
      else
        -- Rate limited — calculate retry-after
        local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
        local retryAfter = oldest[2] and (tonumber(oldest[2]) + window - now) or window
        return {count, limit, retryAfter}
      end
      `,
      1,
      windowKey,
      now,
      windowMs,
      rateLimit
    ) as number[];

    const [current, limit, retryAfter] = result;

    // Set standard rate limit headers
    c.header("X-RateLimit-Limit", String(limit));
    c.header("X-RateLimit-Remaining", String(Math.max(0, limit - current)));
    c.header("X-RateLimit-Reset", String(Math.ceil((now + 60_000) / 1000)));

    if (retryAfter > 0) {
      c.header("Retry-After", String(Math.ceil(retryAfter / 1000)));
      return c.json(
        { error: "Rate limit exceeded", retryAfter: Math.ceil(retryAfter / 1000) },
        429
      );
    }

    await next();
  };
}

Step 4: Build the Usage Analytics API

Developers want to see how their keys are performing — request counts, error rates, latency trends. This data is aggregated per-key per-day for efficient querying.

typescript
// src/routes/portal.ts — Developer portal API endpoints
import { Hono } from "hono";
import { z } from "zod";
import { createApiKey, revokeKey } from "../services/key-service";
import { pool } from "../db";

const portal = new Hono();

// List all keys for the authenticated account
portal.get("/keys", async (c) => {
  const accountId = c.get("accountId");

  const { rows } = await pool.query(
    `SELECT id, name, key_prefix, scopes, rate_limit_per_minute, 
            allowed_ips, expires_at, revoked_at, last_used_at, total_requests, created_at
     FROM api_keys WHERE account_id = $1 ORDER BY created_at DESC`,
    [accountId]
  );

  return c.json({
    keys: rows.map((k) => ({
      ...k,
      status: k.revoked_at
        ? "revoked"
        : k.expires_at && new Date(k.expires_at) < new Date()
          ? "expired"
          : "active",
    })),
  });
});

// Create a new API key
portal.post("/keys", async (c) => {
  const accountId = c.get("accountId");
  const body = await c.req.json();
  const params = CreateKeySchema.parse(body);

  // Enforce per-account key limit
  const { rows: existing } = await pool.query(
    "SELECT COUNT(*) as count FROM api_keys WHERE account_id = $1 AND revoked_at IS NULL",
    [accountId]
  );
  if (existing[0].count >= 25) {
    return c.json({ error: "Maximum 25 active keys per account" }, 400);
  }

  const result = await createApiKey(accountId, params);

  return c.json({
    ...result,
    warning: "Store this key securely — it won't be shown again.",
  }, 201);
});

// Revoke a key
portal.delete("/keys/:keyId", async (c) => {
  const accountId = c.get("accountId");
  const { keyId } = c.req.param();

  const revoked = await revokeKey(keyId, accountId);
  if (!revoked) return c.json({ error: "Key not found or already revoked" }, 404);

  return c.json({ success: true, message: "Key revoked immediately" });
});

// Usage analytics for a specific key
portal.get("/keys/:keyId/usage", async (c) => {
  const accountId = c.get("accountId");
  const { keyId } = c.req.param();
  const days = Number(c.req.query("days") || 30);

  // Verify key belongs to account
  const { rows: keyCheck } = await pool.query(
    "SELECT id FROM api_keys WHERE id = $1 AND account_id = $2",
    [keyId, accountId]
  );
  if (keyCheck.length === 0) return c.json({ error: "Key not found" }, 404);

  // Daily usage breakdown
  const { rows: daily } = await pool.query(
    `SELECT date, 
            SUM(success_count) as successes,
            SUM(error_count) as errors,
            SUM(total_latency_ms) / NULLIF(SUM(success_count + error_count), 0) as avg_latency_ms
     FROM key_usage
     WHERE key_id = $1 AND date >= CURRENT_DATE - $2::int
     GROUP BY date ORDER BY date`,
    [keyId, days]
  );

  // Top endpoints
  const { rows: endpoints } = await pool.query(
    `SELECT endpoint, 
            SUM(success_count) as successes,
            SUM(error_count) as errors
     FROM key_usage
     WHERE key_id = $1 AND date >= CURRENT_DATE - $2::int
     GROUP BY endpoint ORDER BY successes DESC LIMIT 10`,
    [keyId, days]
  );

  return c.json({
    daily,
    endpoints,
    summary: {
      totalRequests: daily.reduce((s, d) => s + Number(d.successes) + Number(d.errors), 0),
      errorRate:
        daily.reduce((s, d) => s + Number(d.errors), 0) /
        Math.max(1, daily.reduce((s, d) => s + Number(d.successes) + Number(d.errors), 0)),
      avgLatencyMs:
        daily.reduce((s, d) => s + (Number(d.avg_latency_ms) || 0), 0) /
        Math.max(1, daily.length),
    },
  });
});

// Roll a key: create new key with same config, revoke old one
portal.post("/keys/:keyId/roll", async (c) => {
  const accountId = c.get("accountId");
  const { keyId } = c.req.param();

  const { rows } = await pool.query(
    "SELECT name, scopes, rate_limit_per_minute, allowed_ips, expires_at FROM api_keys WHERE id = $1 AND account_id = $2 AND revoked_at IS NULL",
    [keyId, accountId]
  );
  if (rows.length === 0) return c.json({ error: "Key not found" }, 404);

  const oldKey = rows[0];

  // Create replacement key with same configuration
  const newKey = await createApiKey(accountId, {
    name: `${oldKey.name} (rolled)`,
    scopes: oldKey.scopes,
    rateLimitPerMinute: oldKey.rate_limit_per_minute,
    allowedIPs: oldKey.allowed_ips,
    expiresAt: oldKey.expires_at,
  });

  // Revoke old key
  await revokeKey(keyId, accountId);

  return c.json({
    newKey,
    revokedKeyId: keyId,
    warning: "Old key is immediately invalid. Update your integration with the new key.",
  });
});

export default portal;

Results

After launching the developer portal:

  • Key-related support tickets dropped from 15/week to 3/week — developers self-serve key creation, rotation, and scope management
  • Compromised key response time: from 4 hours to 30 seconds — developers revoke and roll keys themselves via the dashboard; Redis pub/sub propagates revocation across all servers instantly
  • API abuse detection improved — per-key usage analytics revealed two accounts generating 10x normal traffic for scraping; scoped keys and IP allowlists blocked the abuse without affecting other customers
  • Developer onboarding time cut from 2 days to 15 minutes — new customers get API keys immediately instead of waiting for support email responses
  • Zero plaintext key storage — SHA-256 hashing means even a database breach doesn't expose usable keys