API Keys & Personal Access Tokens: Issue, Scope, Rotate, Revoke Without Locking Customers Out
API Key Strategy for Your New SaaS
Goal: Ship API key infrastructure customers can trust — keys generated with strong entropy, stored hashed (never reversibly), scoped to specific permissions, expirable, rotatable, and revocable from a self-serve UI. Avoid the failure modes where founders store raw keys in the database (one breach = every customer's secrets leaked), skip per-key scoping (a key meant for "read invoices" can also delete the workspace), or never build rotation (customers find out their key is compromised after the breach, not before).
Process: Follow this chat pattern with your AI coding tool such as Claude or v0.app. Pay attention to the notes in [brackets] and replace the bracketed text with your own content.
Timeframe: Key generation + hashing + verification shipped in 1-2 days. Scoping + rotation + revocation in week 1. Audit + monitoring in week 2. Quarterly review baked in from launch onward.
Why Most Founder API Key Systems Are Broken
Three failure modes hit founders the same way:
- Storing raw keys in the database. Founder writes
keystable with asecretcolumn holding the raw key. Six months later, a SQL injection or backup leak exposes the table. Every customer's API key is now public. The founder spends the next week emailing rotation instructions to 400 customers, and 30% don't see the email in time. - No scoping. Customer integrates with your API and the key has full account permissions because that's all you offer. The customer's CI server gets compromised; the attacker now has full access to the customer's account, not just the read-only access the integration needed.
- No rotation, no revocation, no UI. Customer's intern leaves with the laptop that has your API key in
~/.zshrc. Customer asks to revoke it. You don't have a UI for it; you write a one-off SQL UPDATE; you forget to invalidate any cached auth state. The intern keeps working access for 3 days.
The version that works is structured: generate keys with strong entropy, store only a hash, scope every key to specific permissions, support expiration and rotation, expose self-serve UI, audit every authentication, and notify customers about anomalies.
This guide assumes you have already done Public API (API keys gate the API), have considered Audit Logs (every key use should be logged), and have shipped Outbound Webhooks (signing secrets follow similar lifecycle patterns).
1. Decide What an "API Key" Means in Your Product
Before writing code, decide what category of credential you're building. Different uses, different patterns.
Help me decide which credential types my product needs.
The four common categories:
**1. Account API keys** (most common starting point)
- One or more keys per customer account
- Long-lived; manually rotated
- Used for server-to-server integrations
- Example: Stripe's `sk_live_…` keys
**2. Personal access tokens (PATs)**
- Tied to a specific user, not the account
- Carry that user's permissions
- Used for personal automation, CI/CD, dev scripts
- Example: GitHub PATs, Linear PATs
**3. Scoped service tokens**
- Tied to a specific integration or use case
- Narrowed to a permission scope
- Often expirable
- Example: a token scoped to "read:invoices" for an accounting integration
**4. OAuth access tokens** (if your API has third-party app developers)
- Issued via OAuth 2.0 flow on behalf of users
- Short-lived with refresh tokens
- Carry user-granted scopes
- Example: Google Workspace OAuth tokens
For my product, ask:
- Will customers integrate via server-to-server (account keys), via end-user flows (OAuth), or both?
- Do customers need personal automation that's tied to one user (PATs)?
- Do customers need to grant limited access to third-party apps (scoped tokens or OAuth)?
- Is my API surface large enough to merit per-scope keys, or is one "all access" key sufficient for v1?
Output:
1. The credential types I'm shipping in v1 (start with 1-2; add later)
2. The credential types I'm explicitly NOT shipping yet (write down why)
3. The naming convention (prefixes like `sk_`, `pat_`, `tok_`)
4. The relationship to user / account models
The biggest mistake at v1 is shipping every credential type at once. Pick the one customers actually need first (usually account API keys), ship it well, add others later. A well-built single credential type beats a half-built collection.
2. Generate Keys With Strong Entropy and a Recognizable Prefix
How you generate the key affects security, usability, and detection-in-the-wild.
Help me design the key generation function.
**The format I'm using**:
`<prefix>_<environment>_<random>` — example: `sk_live_a7f8d9e2c4b1...`
Where:
- `prefix`: 2-4 chars indicating type (`sk` = secret key, `pk` = public key, `pat` = personal access token, `tok` = scoped token)
- `environment`: optional but recommended — `live` / `test` so the customer can tell at a glance
- `random`: 32+ bytes of cryptographic randomness, base62 or base64url encoded
**Why the prefix matters**:
- **Customers can identify the type at a glance** when debugging
- **Secret scanning**: GitHub Secret Scanning, GitGuardian, and similar tools detect well-known prefixes and notify on commits — your customers (and you) get warned when keys leak to public repos
- **Log redaction**: easy to pattern-match for redaction in logs
- **Debugger ergonomics**: easy to spot in a stack trace
**Generation code (Node.js example)**:
```ts
import crypto from "crypto"
export function generateApiKey(env: "live" | "test" = "live") {
const random = crypto.randomBytes(32).toString("base64url")
return `sk_${env}_${random}`
}
Critical generation rules:
- Use
crypto.randomBytes()(or equivalent) — NEVERMath.random(), NEVER UUID v4 alone for the secret portion (UUID v4 has only 122 bits of entropy and a fixed format; not enough for keys). - At least 32 bytes (256 bits) of entropy. This is non-negotiable for production secrets.
- Include a checksum (optional but useful): append a CRC32 or short HMAC of the key body so typos are detectable client-side without a server roundtrip. GitHub does this with their tokens.
- Register your prefix with secret-scanning services. GitHub has a partner program for this. When a key with your prefix is committed to a public repo, GitHub notifies you, and you can revoke it before damage is done.
Don't:
- Generate keys client-side in the browser
- Use predictable inputs (timestamp, user ID, sequence number) as part of the random portion
- Use a custom "encryption" or "obfuscation" layer instead of pure randomness
- Reuse keys across environments
Output:
- The key generation function in [your language]
- The chosen prefix scheme
- The plan to register the prefix with GitHub Secret Scanning (if applicable)
- The format documentation for customer-facing docs
The single most overlooked decision: **the prefix.** A well-chosen prefix with secret-scanning registration pays back the day a customer accidentally commits a key to a public repo and GitHub catches it for you. Worth the 30-minute setup.
---
## 3. Store Only Hashes — Never Raw Keys
This is non-negotiable. Storing raw keys is the SaaS equivalent of storing plaintext passwords.
Help me design the key storage and verification.
The pattern:
When a key is generated:
- Generate the raw key (per step 2)
- Compute a hash:
hash = sha256(raw_key)— fast hash is fine for high-entropy secrets; you don't need bcrypt/argon2 here - Store ONLY the hash in the
api_keystable - Show the raw key to the customer ONCE in the response; never persist it
When a request comes in with an Authorization: Bearer <key> header:
- Compute
incoming_hash = sha256(submitted_key) - Look up
api_keys WHERE hash = incoming_hash - If found and not revoked / expired: authenticate
- If not found: 401
Schema:
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id),
user_id UUID REFERENCES users(id), -- NULL for account keys; set for PATs
name TEXT NOT NULL, -- customer-set label
hash TEXT NOT NULL UNIQUE, -- sha256 of raw key
prefix_preview TEXT NOT NULL, -- first 8 chars for UI display ("sk_live_a7f8...")
scopes TEXT[] NOT NULL DEFAULT ARRAY['*'], -- scoped permissions
expires_at TIMESTAMP, -- NULL = no expiration
last_used_at TIMESTAMP, -- updated on each successful auth
last_used_ip TEXT,
revoked_at TIMESTAMP, -- soft-delete pattern
revoked_by UUID REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id)
);
CREATE INDEX idx_api_keys_account ON api_keys(account_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_api_keys_hash ON api_keys(hash) WHERE revoked_at IS NULL;
Why SHA-256 and not bcrypt/argon2 for keys (unlike passwords):
- Keys have ~256 bits of entropy from
crypto.randomBytes(32). Brute-forcing is computationally infeasible regardless of hash speed. - Per-request password-style hashing (bcrypt/argon2) would add 50-200ms to every API call. Unacceptable for an API.
- For passwords (low entropy, human-typed), use bcrypt/argon2. For API keys (high entropy, machine-generated), SHA-256 is correct.
Show-once UI flow:
- Customer clicks "Create API key"
- They name it ("Production Vercel deploy", "GitHub Actions CI", etc.)
- They pick scopes (per step 4)
- Submit → server returns
{ key: "sk_live_…", id: "…", prefix_preview: "sk_live_a7f8..." }in the response - UI displays the key with a "Copy" button and a stark warning: "This is the only time you'll see this key. Copy it now."
- After dismissing, the UI never shows the full key again — only the
prefix_preview
Don't:
- Store the raw key, even temporarily
- Email keys to customers
- Show keys in URL parameters
- Log keys (even masked — log the
idinstead) - Allow re-fetching of a generated key
Output:
- The api_keys table migration
- The generation + hashing flow
- The show-once UI component
- The verification middleware
- The "I lost my key" flow (you don't recover it; you generate a new one)
The single most important property: **a database leak should not compromise customer keys.** SHA-256 storage achieves this for keys with 256 bits of entropy. If you ever feel tempted to "encrypt the keys so we can show them again," you're solving the wrong problem.
---
## 4. Scope Every Key
A key without scopes is a key that can do everything. Scopes are how you give customers least-privilege control.
Design the scoping model.
The two common patterns:
Pattern A: Permission scopes (REST-style)
- Scopes are strings like
read:invoices,write:invoices,admin:webhooks - Each API route requires one or more scopes
- Stored as
scopes TEXT[]on the api_keys table - Customer picks scopes at key creation; can edit (or rotate to change)
Pattern B: Resource scopes (object-level)
- Scopes restrict which specific resources the key can access
- Example: "this key can only access workspace_id = 42"
- Stored as a separate
api_key_resource_scopestable - Useful for multi-tenant products where one key should be limited to one tenant
Most indie SaaS need Pattern A in v1. Pattern B comes later if customers ask.
The scope catalog (start small, expand carefully):
- * — full access (legacy / admin keys; show a warning at creation)
- read:[resource] — read-only access to [resource]
- write:[resource] — read + write access
- admin:[resource] — read + write + delete + configuration changes
Examples for a typical SaaS:
read:projects/write:projects/admin:projectsread:users/admin:usersread:webhooks/admin:webhooksread:billing/admin:billing
Design rules:
- Default to read-only. When a customer creates a key without picking scopes, default to
read:*not*. Make destructive scopes opt-in. - Show what each scope grants. A customer creating a key needs to see "this key will be able to: delete projects, modify users, ..." in plain English at creation time.
- Don't add scopes faster than you can deprecate them. Every scope you ship is forever. Plan the catalog before shipping.
- Consider an
*(full-access) scope for migration ease but warn at creation: "This key has full access. We recommend scoped keys for all new integrations." - Validate scopes per request. Every route handler checks
if (!key.scopes.includes(requiredScope) && !key.scopes.includes('*')) return 403.
Implementation in middleware:
function requireScope(scope: string) {
return (req, res, next) => {
if (!req.apiKey) return res.status(401).end()
if (req.apiKey.scopes.includes(scope) || req.apiKey.scopes.includes('*')) {
return next()
}
return res.status(403).json({ error: `requires scope: ${scope}` })
}
}
router.delete('/projects/:id', requireScope('admin:projects'), handler)
Output:
- The scope catalog for v1
- The migration to add scopes to
api_keys - The middleware enforcement code
- The customer-facing scope picker UI
- The plain-English scope description copy
The biggest UX win: **showing customers what each scope grants in plain English at creation time.** "This key will be able to delete projects, change billing, and revoke other API keys" makes customers think twice before clicking the `*` checkbox. That moment of friction prevents 90% of over-scoped keys.
---
## 5. Support Rotation Without Downtime
Rotation that requires downtime won't be done. Build it so customers can rotate without breaking integrations.
Design the rotation flow.
The pattern:
When a customer wants to rotate a key:
- Customer creates a NEW key (with the same scopes if they want)
- Customer updates their integration to use the new key
- Customer revokes the OLD key when ready
This is called rotation by replacement: at no point does the customer have a "key being rotated" with two valid values. Each key is independent.
Why this is better than "true rotation":
- No window where two values are valid (would create deployment race conditions)
- Customer controls the cutover (no surprise revocation)
- Simple to implement
- Standard pattern customers already know from Stripe, GitHub, AWS
UI flow:
On the API keys page:
- Each key shows: name, prefix preview, scopes, last used, expiration, "Rotate" button, "Revoke" button
- "Rotate" creates a new key with the same name (suffixed " (rotated)") and same scopes; immediately shows the new value once
- The OLD key remains valid until the customer explicitly revokes it
- A subtle "Pending rotation" badge appears next to the old key for 30 days, with a banner: "You created a replacement for this key on [date]. Revoke once your integrations are updated."
Auto-revocation safeguards (optional but recommended):
- After 30 days post-rotation: notify the customer "Old key still active. Revoke if no longer needed."
- After 90 days: keep notifying; never auto-revoke (customers hate surprise revocations)
Critical rules:
- Don't auto-revoke. A customer's old key being silently disabled mid-deploy is the worst possible UX. Notify; let them decide.
- Make rotation a 1-click action. If it takes 4 clicks, customers won't do it.
- Show "last used" clearly. Customers rotate based on whether the old key is still being used. Make
last_used_atprominent. - Allow setting expiration on creation. A key with a 90-day expiration self-rotates by forcing the conversation.
- Send creation/rotation/revocation events to Audit Logs.
Output:
- The rotation UI flow
- The "pending rotation" badge logic
- The notification email templates (rotation reminder, revocation confirmation)
- The audit log entries
- The expiration policy (default off; available as opt-in)
The single biggest barrier to rotation in practice: **customers don't know if the old key is still being used.** Make `last_used_at` visible and current; rotation goes from "scary" to "obvious."
---
## 6. Build the Self-Serve Revocation UI
When a key leaks, the customer needs to revoke it in 30 seconds, not file a support ticket.
Design the revocation flow.
The customer-facing UI at /dashboard/api-keys:
Each key row has a "Revoke" button. On click:
- Confirm dialog: "Revoke key '[name]'? This is immediate and cannot be undone. Any integration using this key will stop working."
- On confirm: set
revoked_at = NOW(),revoked_by = current_user.id - Update the row to show as revoked (greyed out, "Revoked [date] by [user]")
- Send a revocation event to Audit Logs
- Send an email to admin users on the account: "API key '[name]' was revoked on [date] by [user]"
Verification path:
The auth middleware should:
- Look up the key by hash
- Check
revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) - If revoked or expired: return 401 with body
{ "error": "key_revoked" }or"key_expired" - The customer''s integration sees a clear error and knows to update its key
Caching gotcha:
If your API caches authentication state (Redis, in-memory), revocation must invalidate the cache. Patterns:
- Short cache TTL (10-30 seconds): acceptable revocation lag
- Pub/sub on revoke event: invalidate cache entries for the revoked key
- No cache: simplest; query the DB every request (often fine for indie scale)
Bulk revocation (for incident response):
When something goes very wrong, the customer needs to revoke ALL keys at once. Build:
- "Revoke all keys for this account" button (admin-only; double-confirm)
- "Revoke all keys created before [date]" (for narrowing down a known compromise window)
- Audit-logged with reason field
Don't:
- Soft-delete keys without setting
revoked_at(the row should remain for audit) - Allow "un-revoke" — revocation must be permanent
- Cache so aggressively that revocation takes more than 30 seconds to propagate
Output:
- The revocation UI
- The verification middleware with cache invalidation
- The bulk-revocation flow
- The notification emails
- The error response shape so customers can handle revocation cleanly client-side
The biggest revocation pitfall: **caching auth state too aggressively.** A customer revokes a key during an incident, then waits 5 minutes for the cache to expire while the leaked key continues working. Cache TTL ≤ 30 seconds OR explicit invalidation on revoke. Pick one.
---
## 7. Audit Every Authentication Event
Without audit, you have no way to investigate "did this key leak?" after the fact.
Design the audit log integration for API key events.
Per Audit Logs, log these events:
Lifecycle events:
api_key.created— who created it, with what scopesapi_key.rotated— link to the new keyapi_key.revoked— who revoked it, why (if reason supplied)api_key.expired— automatic on hitting expires_at
Authentication events (high volume — be thoughtful):
- Don''t log every successful API call as an audit event (use request logs / observability providers)
- DO log: first use of a key from a new IP, first use after >30 days dormant, failed authentications (signature mismatch, expired key)
- DO log: scope-violation attempts (key tried to access a scope it doesn''t have — possible compromise indicator)
Anomaly detection (Phase 2):
- Sudden volume spike on a key — possible compromise
- Geographic anomaly (key used from US for 2 years, suddenly seen from RU)
- Off-hours usage (customer tagged the key as "CI", but it''s being used at 3am from a residential IP)
Customer-facing audit feed at /dashboard/api-keys/[id]/activity:
- Shows last 1000 authentication events for the key
- Filters: failed only, by IP, by date range
- Lets the customer investigate "did this key get used somewhere I don''t recognize?"
Don''t:
- Log raw keys in audit events (log the key id and prefix preview only)
- Store IPs longer than your privacy policy permits (typically 90 days)
- Build complex anomaly detection in v1; basic logging first
Output:
- The audit event schema
- The event-emission code in the auth middleware
- The customer-facing activity feed
- The plan for anomaly detection in Phase 2 (optional)
The single most useful audit signal in practice: **"first use from a new IP."** Customers often integrate keys in known places (CI, prod servers, developer laptops). A new IP often means a new legitimate integration — but sometimes it means a leaked key. Surface it; let the customer judge.
---
## 8. Document Authentication for Customers
Customers won't integrate well if they can't read clear docs.
Help me draft the customer-facing API key documentation.
Sections:
Overview
- What API keys are
- The key types your product offers (account keys, PATs, scoped tokens)
- Where to create them (link to dashboard)
Generating a key
- Step-by-step UI walkthrough
- Naming conventions you recommend
- Scope picker explained
- The "show once" warning made prominent
Using the key
- Authentication header format:
Authorization: Bearer <key> - Example curl request
- Examples in TypeScript, Python, Ruby, Go (the languages your customers use)
- Common errors and what they mean: 401 (invalid / revoked / expired), 403 (scope missing)
Best practices
- Store in a secrets manager — never check into source control
- Use environment variables; load at startup
- Rotate periodically (every 90 days recommended)
- Use scoped keys — narrow the blast radius
- One key per integration — easier to revoke without affecting others
- Monitor the activity feed for unexpected use
Rotation guide
- Why rotate
- How: create new, update integration, revoke old
- Recommended cadence
If a key leaks
- Revoke immediately from the dashboard
- Audit usage from the activity feed
- Generate a replacement
- Notify your security team (sample wording for the customer''s internal escalation)
FAQ
- Can I view a key after creation? No.
- Can I "un-revoke" a key? No.
- Can I rename a key? Yes.
- Can I change a key''s scopes? No — create a new key with the right scopes and rotate.
- What''s the rate limit on API key auth? Link to your rate-limiting docs.
Output:
- The docs page structure
- Working code samples in 3+ languages
- The "if a key leaks" runbook
- The FAQ
The biggest predictor of safe customer use: **clear "if a key leaks" docs they can find before they need them.** When a key leaks, customers panic. A pre-written runbook (revoke → audit → rotate → notify) gets them to safe state in under 5 minutes.
---
## 9. Monitor Key Health
Without monitoring, you won't know which keys are stale, abused, or compromised.
Design key health monitoring.
Internal metrics to track:
api_key.auth.success/api_key.auth.failure— count by reasonapi_key.usage.by_account— top accounts by request volume (early signal of customers hitting limits)api_key.dormant.count— keys withlast_used_at < NOW() - 90 days(cleanup candidates)api_key.rotation.cadence— average days between key rotations per account (proxy for security hygiene)api_key.scope.distribution— what % of keys have*scope vs scoped (track downward over time)
Alerts:
- Unusual volume on a single key: 10x normal rate over 5 minutes → notify customer (possible compromise)
- Repeated 401s for a known key: customer may have just rotated and is seeing the deprecated-key error (informational)
- Repeated scope-violation 403s: someone is probing the API with a low-scope key trying to escalate (alert + investigate)
- Key created outside normal hours from a new IP: low-confidence signal but worth surfacing in the customer''s audit feed
Customer-facing health surfaces:
- Dashboard widget: "X of your keys have been dormant for >90 days. Consider revoking them."
- Email digest (monthly, opt-out): "API key activity for [account]: top keys, dormant keys, recent rotations"
Don''t:
- Auto-revoke dormant keys without confirmation (will break integrations the customer forgot they had)
- Build complex ML anomaly detection in v1 — simple thresholds first
- Spam customers with health emails — make them opt-out, not opt-in
Output:
- The metrics emission code
- The alert rules
- The dashboard widgets
- The monthly digest template (optional)
---
## 10. Quarterly Review
API key infrastructure rots. Quarterly review keeps it healthy.
The quarterly review checklist.
Health metrics:
- What % of keys are scoped vs
*? Target trends upward. - What % of accounts have rotated keys in the last 90 days?
- What % of keys have been dormant >90 days?
- Authentication failure rate trend?
- Number of customer-reported leaked keys this quarter?
Catalog hygiene:
- Are there scopes nobody uses? (Candidates for deprecation in a v2.)
- Are there scopes customers ask for that we don''t have? (Candidates to add.)
- Is the
*scope still warranted, or can we sunset it?
Security review:
- Has the prefix been registered with GitHub Secret Scanning? Verify the registration is still active.
- Are there any keys with our prefix detected in public repos this quarter? Investigate, notify customers.
- Has anyone reported a key compromise? Run the post-mortem.
Documentation review:
- Are sample-code snippets still working?
- Has the recommended rotation cadence held up in practice?
Feature requests review:
- Are customers asking for resource scopes (Pattern B from step 4)?
- Are customers asking for OAuth?
- What''s the case for adding them in the next quarter?
Output:
- Health snapshot
- 3 actions to improve next quarter
- 1 scope or feature to add
- 1 thing to deprecate
---
## What "Done" Looks Like
A working API key system in 2026 has:
- **Strong-entropy generation** with a recognizable prefix registered with secret-scanning services
- **Hashed storage** — never raw keys in the database
- **Scoping** with a published scope catalog and middleware enforcement
- **Show-once UI** with a clear "this is your only chance" warning
- **Self-serve rotation** without downtime
- **Self-serve revocation** with cache-aware invalidation
- **Audit logs** for create / rotate / revoke and selected auth events
- **Customer-facing activity feed** so customers can investigate suspicious use
- **Health monitoring** with alerts on volume anomalies and scope violations
- **Public docs** with sample code in 3+ languages and an "if a key leaks" runbook
- **Quarterly review** baked into the team rhythm
The system you build in week 1 will look broken by year 2 if you don't review it. Customers' security teams will ask questions during enterprise sales — being able to say "scoped keys, hashed storage, audit log, rotation, revocation, secret scanning" closes deals. Being unable to answer those questions loses them.
---
## See Also
- [Public API](public-api-chat.md) — API keys gate the API
- [Outbound Webhooks](outbound-webhooks-chat.md) — signing secrets follow similar lifecycle patterns
- [Inbound Webhooks](inbound-webhooks-chat.md) — webhook signing complements API key auth
- [Audit Logs](audit-logs-chat.md) — every key event logged
- [SSO & Enterprise Auth](sso-enterprise-auth-chat.md) — enterprise customers expect both
- [Auth Providers](https://www.vibereference.com/auth-and-payments/auth-providers) — Clerk, Supabase, Better Auth often handle the underlying user auth
- [Observability Providers](https://www.vibereference.com/devops-and-tools/observability-providers) — high-volume request logs go here, not audit
[⬅️ Growth Overview](README.md)