Back to Explore
GuideVibeFix 编辑部Updated Oct 12, 2026

Stop Handing Out Master Keys: Customer-Facing API Key Issuance & Scopes for Vibe Projects

Opening up your API means issuing keys to users — and most solo teams get it wrong: plaintext storage, one god-key for everything, no revocation, no usage attribution. This guide gives you a copy-paste-ready system: key format design, hash-only storage, scope permission models, rotation/revocation SOPs, and per-key rate limiting with usage attribution — with TypeScript templates, Prisma schemas, and a one-week rollout checklist.

API key creation form showing key name, associated user, permissions, rate limit override and expiry fields

Your SaaS is running, and users start asking: "Do you have an API? I'd like to pipe your data into my system."

That's a good sign — it means the product is genuinely valuable. But 90% of solo teams then fall into the same trap: hand-generate a random string, stuff it into the database in plaintext, send it to the user, and pray nothing ever goes wrong.

I've seen too many keys like this: one key rules everything — reading, writing, and deleting data all ride on it. When a key leaks and you try to revoke it, you discover where api_key = 'xxx' hardcoded everywhere. When you want usage-based billing at month's end, you realize you can't tell which key made how many requests. Worse, the keys sit in your database in plaintext, so the day a database backup leaks, you've gift-wrapped every user's front-door key in one package.

This article isn't about "stopping hackers." It's about the operational lifecycle of keys: what a key should look like, what belongs in your database, what a key is allowed to do, how a key dies gracefully, and how the money a key spends gets attributed back to it. When you finish, you'll be able to copy a complete "revocable, scoppable, billable" customer API key system.

One boundary first: this is about customer-facing keys — keys you issue to users. Managing your own secrets (keeping them out of prompts, repos, and logs) is a different topic with completely different practices. Don't mix the two.

1. What a Key Should Look Like: Prefix + Entropy + Display Suffix

Look at how Stripe and OpenAI format their keys: sk_live_51H..., sk-proj-.... That's not aesthetics — it's engineering. A well-designed customer key has three parts:

  • Prefix: identifies whose key this is and which environment. vf_live_, vf_test_. The prefix does two hard jobs: instant recognition in logs (even redacted to prefix + last 4, you can locate it), and stopping users from pasting a test key into production without noticing.
  • Entropy: the actual security. Generated with a cryptographically secure RNG, at least 192 bits (24 bytes). Don't settle for UUID v4 — not because it's broken, but because its format bits waste entropy and it screams "generated by hand."
  • Display suffix: the last 4 characters, stored in plaintext, purely for humans. "Your key ends in …8f3a" — the only way a user with five keys tells them apart.

Some people add a checksum, Stripe-style CRC. From real-world experience, that's over-engineering for a solo team: checksums guard against "user mistypes one character while copying a key by hand," and in 2026 users copy-paste. More implementation complexity, near-zero payoff. My call: prefix + 192-bit entropy + last-4 display is enough.

Here's a copy-paste-ready generation and verification template (TypeScript, Node 20+):

import { randomBytes, createHash, timingSafeEqual } from "node:crypto";

const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const PREFIX_LIVE = "vf_live_";
const PREFIX_TEST = "vf_test_";

function randomBase62(bytes: number): string {
  const buf = randomBytes(bytes);
  let out = "";
  for (const b of buf) out += BASE62[b % 62];
  return out;
}

export interface IssuedKey {
  /** The full key in plaintext — shown exactly once */
  plaintext: string;
  /** SHA-256 hash for database storage */
  keyHash: string;
  /** Stored prefix, for fast lookup + log redaction */
  prefix: string;
  /** Stored last 4 chars, shown to the user */
  last4: string;
}

export function issueApiKey(env: "live" | "test" = "live"): IssuedKey {
  const prefix = env === "live" ? PREFIX_LIVE : PREFIX_TEST;
  // 32 bytes = 256 bits of entropy, ~43 chars in base62
  const secret = randomBase62(32);
  const plaintext = prefix + secret;
  return {
    plaintext,
    keyHash: createHash("sha256").update(plaintext).digest("hex"),
    prefix,
    last4: secret.slice(-4),
  };
}

/** Verify a presented key: hash first, then compare in constant time */
export function verifyApiKey(presented: string, storedHashHex: string): boolean {
  const presentedHash = createHash("sha256").update(presented).digest();
  const storedHash = Buffer.from(storedHashHex, "hex");
  if (presentedHash.length !== storedHash.length) return false;
  return timingSafeEqual(presentedHash, storedHash);
}

Three details worth noting:

  1. plaintext exists only at issuance time. Show it to the user once, then never display the full key again. User lost it? Issue a new one — there is no "recover key" feature.
  2. Verify with timingSafeEqual, not ===, to block timing attacks. Five minutes of work; no reason to skip it.
  3. Is SHA-256 enough? The key itself is 256 bits of randomness — there's no "weak password to brute-force" premise, so you don't need a slow hash like bcrypt. A slow hash would only drag down your per-request auth path. Fast hash + high entropy is the right combination.

/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/8d2e8077-27d5-4312-bad9-5f8567237a07.png

The screenshot above shows a typical key-management UI: name, associated user, permission selection, and expiry at issuance time. Your admin panel needs at least these four fields — otherwise in six months you won't be able to tell your own keys apart.

2. What Goes in the Database: Hash Only, Plus Audit by Design

Storing keys in plaintext is a red line — not just because of "leaks," but because your ops surface touches them by nature: database backups, logs, ORM debug output, staging dumps. Any one of those can carry a plaintext key out the door. Hash storage reduces the damage of the common "read-only database leak" scenario to zero.

Prisma schema template (Postgres):

model ApiKey {
  id          String    @id @default(cuid())
  userId      String
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  name        String    // "Production - ERP sync", filled in by the user
  keyHash     String    @unique // SHA-256 hex — the only thing we store
  prefix      String    // "vf_live_", for log redaction and fast filtering
  last4       String    // last 4 chars for display

  scopes      String[]  // ["invoices:read", "invoices:write"], see section 3
  status      ApiKeyStatus @default(ACTIVE)

  rateLimitPerMin Int?  // per-key override, null = global default
  expiresAt   DateTime?
  lastUsedAt  DateTime?
  createdAt   DateTime  @default(now())
  revokedAt   DateTime?

  events      ApiKeyEvent[]
  @@index([userId, status])
}

enum ApiKeyStatus {
  ACTIVE
  ROTATING   // rotation grace period: old and new keys both valid
  REVOKED
  EXPIRED
}

/// Immutable audit trail: every issuance, rotation, revocation, expiry logged here
model ApiKeyEvent {
  id        String   @id @default(cuid())
  apiKeyId  String
  apiKey    ApiKey   @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
  type      String   // ISSUED | ROTATED | REVOKED | EXPIRED | USED_FROM_NEW_IP
  actorId   String?  // who did it (the user or an admin)
  ip        String?
  meta      Json?
  createdAt DateTime @default(now())
  @@index([apiKeyId, createdAt])
}

Three operational decisions are baked into this schema:

  1. Revocation never means deleting the row. It's a state transition, ACTIVE → REVOKED; the row lives forever. Deleting breaks the audit chain — three months later when a user asks "why did my integration break on May 3rd," you'll find no such key and have nothing to say.
  2. The ROTATING state is the heart of the rotation SOP (section 4). Without this middle state, rotation means "kill the old key first, then issue the new one" — and the user's production jobs die at midnight, followed by angry messages.
  3. lastUsedAt is your zombie-key radar. Keys unused for 90 days get a "consider revoking" email; after 180 days they're auto-expired. Every big company does this hygiene; a solo team can do it with one cron job.

3. What a Key May Do: The Scope Permission Model

"One key to rule them all" is the most common solo-team design — and the most dangerous. The right approach is scopes: at issuance, a key declares which resources it can touch and what operations it may perform, named resource:action.

/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/24c8d4e2-941e-433c-899b-d598f006d210.jpg

The screenshot above shows a "create restricted key" UI — the finer the permissions, the more confidently users hand keys to third parties. Fill in your scope design table along these lines:

ScopeMeaningTypical holder
invoices:readList and view invoicesAnalytics scripts, BI sync
invoices:writeCreate / void invoicesERP integration, finance systems
customers:readRead customer profilesCRM sync
webhooks:manageAdd / remove webhook subscriptionsYour own backend only — don't hand to third parties
billing:readView usage and billsCustomer's own FinOps scripts
admin:*Admin-surface operations (members, key management)Never issued to users, internal only

Three hard rules:

  1. Least privilege by default. The issuance UI pre-checks only :read-style scopes; write access requires the user to expand an "I understand the risk" section. That one extra step blocks half of all misconfigurations.
  2. A scope is a snapshot at issuance, not a live reference. The scopes array on the key row is the source of truth for auth — don't resolve "the user's current role" on every request. Otherwise, when a user downgrades their plan, the permission semantics of old keys become an unresolvable mess.
  3. The auth middleware does exactly one thing: parse the key → look up the hash → check status → check the scope covers the route. No business logic in there. Example:
// Auth middleware skeleton, Express / Hono style
async function requireScope(scope: string) {
  return async (req, res, next) => {
    const presented = req.headers["authorization"]?.replace("Bearer ", "");
    if (!presented) return res.status(401).json({ error: "missing_api_key" });

    const key = await db.apiKey.findUnique({ where: { keyHash: sha256(presented) } });
    if (!key || !verifyApiKey(presented, key.keyHash)) {
      return res.status(401).json({ error: "invalid_api_key" });
    }
    if (key.status !== "ACTIVE" && key.status !== "ROTATING") {
      return res.status(401).json({ error: "api_key_revoked" });
    }
    if (key.expiresAt && key.expiresAt < new Date()) {
      await markExpired(key.id);
      return res.status(401).json({ error: "api_key_expired" });
    }
    if (!key.scopes.includes(scope)) {
      return res.status(403).json({ error: "insufficient_scope", required: scope });
    }
    // Update lastUsedAt async — don't block the request
    void db.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } });
    req.apiKey = key;
    next();
  };
}

// Declare the required scope right on the route — one line
app.get("/v1/invoices", requireScope("invoices:read"), listInvoices);
app.post("/v1/invoices", requireScope("invoices:write"), createInvoice);

4. How a Key Dies: Rotation & Revocation SOP

A key should only die two graceful deaths: rotation (the user swaps it proactively) and revocation (kill it immediately when something's wrong). The ungraceful way is "just delete it" — deleting a row destroys evidence, as covered above.

Rotation SOP (every 90 days, or user-triggered):

  1. User clicks "Rotate" → a new key is generated (same name, same scopes); the old key moves to ROTATING, the new one is ACTIVE.
  2. Both keys work in parallel for 7 days. The gateway accepts both hashes. This is the user's migration window — write it into your docs.
  3. After 7 days, a cron flips overdue ROTATING keys to REVOKED and logs an ApiKeyEvent.
  4. If the old key still sees traffic during the ROTATING window, email the user: "your old key still made X calls — remember to switch to the new one."

Revocation SOP (leak / offboarding / suspicious traffic):

  1. One-click revoke: status goes straight to REVOKED, effective immediately (the gateway reads from DB or cache — keep cache TTL under 60 seconds, or you'll get "revoked but still works for 5 minutes").
  2. Automatically log a REVOKED event with the operator's IP.
  3. Within 24 hours, email the key's user: "your key has been revoked; re-issue one if this was you." Never auto-issue a replacement — make the human confirm.

/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/fc523065-77ce-4257-ad12-a7e4466e9ac3.png

The revocation UI should look like the screenshot above: each key on its own row with a clear revoke action and last-used timestamp. When a user discovers a leak at 3 AM, killing the key should take 10 seconds — not a treasure hunt through your docs for "please contact support@…".

5. What a Key Spent: Per-Key Rate Limiting & Usage Attribution

The final loop of a key system is money: which key made how many calls, cost how much, and should be billed what. Without usage attribution, API billing is guesswork.

Rate limiting: token bucket per key. Global rate limits stop DDoS; per-key limits stop "one user's buggy script blowing up your bill" (and your upstream provider's bill). In Redis it's one Lua script: key it as ratelimit:{keyId}:{window}, read the quota from ApiKey.rateLimitPerMin, fall back to the plan default when null.

Usage attribution: the keyId must be in your request logs. After the gateway authenticates, write keyId into access logs (or an ApiUsageDaily rollup table). The month-end reconciliation SQL looks like this:

-- Per-key usage and error rate for last month: billing + anomaly detection in one query
SELECT
  k.name,
  k.last4,
  u.user_id,
  SUM(u.request_count)          AS total_requests,
  SUM(u.error_count) * 1.0 / NULLIF(SUM(u.request_count), 0) AS error_rate,
  SUM(u.llm_tokens_in + u.llm_tokens_out) AS total_tokens
FROM api_usage_daily u
JOIN api_keys k ON k.id = u.api_key_id
WHERE u.day >= date_trunc('month', now() - interval '1 month')
  AND u.day <  date_trunc('month', now())
GROUP BY k.name, k.last4, u.user_id
ORDER BY total_requests DESC;

If you bill through Stripe metered billing, the integration checklist is three steps: create a meter on the api_requests event, report stripe.billing.meterEvents.create after each successful authenticated call (with keyId in metadata), and let Stripe generate the invoice at month's end. The key point: reporting is best-effort and async — never let Stripe's latency slow down your API responses. Push to a queue, retry 3 times on failure, then log for manual backfill.

6. Anti-Patterns: Three Things Not to Do

Anti-patternWhy it hurtsDo this instead
Mixing customer keys and your own secrets in one tableYour own secret rotation (CI injection, K8s secrets) and customer key lifecycles are completely different; mixing them pits two SOPs against each other — and one over-broad query can scoop up your production secrets tooPhysical separation: api_keys (customers) vs service_credentials (yours), different tables, different DB roles
Revoking by deleting the rowBreaks the audit chain; you can't answer "when did my integration break"; accidental deletes are unrecoverableState machine + immutable ApiKeyEvent trail
Handing every integrator a scopeless "root key"One leak compromises everything; and you lose least-privilege plus per-key attributionScopes required at issuance; offer "one-click read-only key" to lower the UX cost

7. One-Week Rollout Checklist

If you're currently on "one key to rule them all," migrate in this order — each step ships independently:

  1. Days 1–2: add prefix / last4 / keyHash columns; all newly issued keys use the new format. Mark old keys legacy with a 30-day rotation deadline.
  2. Day 3: add scope checks to the auth middleware; declare required scopes on routes; backfill legacy keys with full scopes (for compatibility) while new keys must choose explicitly.
  3. Day 4: revoke/rotate endpoints + ROTATING dual-key logic + the audit event table.
  4. Day 5: per-key rate limiting (Redis token bucket) + keyId in gateway logs.
  5. Days 6–7: usage rollup table + reconciliation SQL verified; zombie-key reminder cron live.

Once this is in place, your API graduates from "a string you lose control of the moment you send it" to a real operational system. The next time a user asks "can I get a key," your answer isn't "sure, one moment" — it's a link: self-serve issuance, self-chosen permissions, self-service rotation. And you finally stop getting woken up at night by "my key leaked, what do I do" messages.

Views 0Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

Concept illustration of the three-layer moderation funnel for solo teams: machine pre-review, user reports, and human final review
Guide
Content Moderation for Solo Teams: A Three-Layer Funnel, 30 Minutes a Week

If your vibe project has anywhere users can type, you need moderation — and it isn't 'deleting posts,' it's tiered triage. This guide maps the three risk surfaces (traditional UGC, user-publishable content, AI-generated output), then builds the funnel: machines do 90% with a keyword-plus-API stack, user reports act as sensors with tiered SLAs, and you spend 30 minutes a week on the 1% of hard cases. Copy-ready guidelines template and disaster breakers included.

Tool TipsProject BuildingIndie Development
Cover concept for the secrets management guide: a keychain in an AI's hand next to a locked vault, warning against handing credentials to AI
Guide
Don't Hand Your Secrets to the AI: Credential Management for Vibe Projects

In the AI coding era, secrets leak because AI actively writes keys into code, comments, and commits — and pushes them. This guide gives vibe coders a five-state credential framework: generate, store, use, rotate, and incident response. Copy-paste ready: layered .env files, pre-commit hooks, production secrets-hosting picks, three AGENTS.md red lines, least-privilege third-party keys, a 30-minute leak SOP, and a monthly 5-minute inspection checklist.

Developer WorkflowGitHubStripe
Diagram of edge-first AI architecture: inference running on the user's device with cloud as fallback
Guide
Push AI Compute to the User: Local/Edge Inference for Vibe Projects

The cheapest GPU is your user's GPU. This guide shows indie teams how to push suitable inference workloads to the browser and edge — WebGPU, transformers.js, WebLLM, llama.cpp, Ollama, Apple MLX compared; 1B–8B quantized models mapped to tasks; an edge-first degradation chain with drop-in fallback code; packaging "data never leaves the device" as a selling point; hard limits on load size, memory and heat; and a two-week pilot SOP. Cloud stays only as the safety net.

DeploymentTool ComparisonIndie Development