Api keys
Design API credentials for a developer tool — prefixed keys (sk_live_/pk_test_), SHA-256 hashing at rest, test vs live modes, scopes, rotation, last-used tracking, and display-once UX. Use when the user wants to add API keys, tokens, or authentication for their API, build a keys page in a dashboard, or handle secrets. Also use when they mention "auth for our API", "personal access tokens", "key rotation", "leaked credentials", or secret scanning, even if they never say "API key".From its SKILL.md
npx -y skills add alexpate/devtool-skills --skill api-keysAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.3 KB, as published. Nobody here has run it
API Keys
The API key is the first artifact of your product a developer ever holds. They see the key before they see a single response body. It is also the credential that ends up in their env files, CI secrets, and (inevitably) a leaked repo. The bar is Stripe's key design plus GitHub's leak-response machinery. Getting this right at design time costs a day; retrofitting a key format costs a migration across every customer.
Before you start
Check for .agents/devtool-context.md and read it if present (product name, API shape, and stack all feed the design). If absent, ask:
- What does the API do, and is there data that's readable client-side (do you need publishable keys, or only secret ones)?
- Is there a dashboard where keys will be created and revoked?
- What's the stack, specifically the database and web framework?
The design, in order of consequence
Positions below are defaults. Deviate only with a reason.
1. Key format: prefixed, high-entropy, checksummed
Every key carries a human-readable prefix identifying your product, the key type, and the mode:
sk_live_Xk29fjB1nQ7wLmZo4pT8Rd # secret key, live mode
sk_test_... # secret key, test mode
pk_live_... / pk_test_... # publishable key (only if you have client-side reads)
Write the format spec down as a registry in your repo and treat it as an API contract:
<prefix>_<mode>_<22 chars base62>[<6 chars base62 CRC32 checksum>]
sk = secret key (server-side, full access per its scopes)
pk = publishable key (client-safe, read-only public data)
rk = restricted key (reserved; add when scoped keys ship)
Why prefixes are non-negotiable:
- A distinctive prefix lets you register with GitHub's secret scanning partner program, so keys leaked in public repos get reported to you (and auto-revoked by you) within minutes instead of after the incident. This alone justifies the pattern; Stripe, Slack, and Plaid all participate.
- Prefixes make keys instantly debuggable. "You're sending a test key to the live endpoint" is diagnosable from the first 8 characters of a support ticket. Unprefixed random strings turn that into a 30-minute investigation.
- Customers can audit their own codebase for your credentials with one
grep -r "sk_live_". Their security team will do exactly this.
Add a checksum segment. GitHub appends a base62-encoded CRC32 to its ghp_ tokens for exactly this reason: scanners (yours, GitHub's, customers' pre-commit hooks) can validate "is this a real key or a lookalike string" offline, without burning an API call per candidate. It's ~6 characters of overhead for a dramatically better leak-detection pipeline.
Key material is 128+ bits of CSPRNG output, full stop. Never derive it from user IDs, timestamps, or anything else: derivation adds structure, and structure is what attackers exploit. 22 base62 characters ≈ 131 bits.
2. Hashing at rest: SHA-256, deliberately not bcrypt
Store sha256(key), and be ready to defend the choice, because someone will file a "you should use bcrypt" issue:
- Slow hashes (bcrypt/argon2) exist to protect low-entropy secrets, i.e. passwords, where attackers can enumerate the input space. A 128-bit random key has no enumerable input space; brute-forcing SHA-256 preimages of it is infeasible regardless of hash speed.
- Slow hashes cost 50 to 200ms per API request, because you verify the key on every call. SHA-256 is microseconds and lets you look the key up by hash with a plain index: O(1) per request, without a per-row comparison loop.
Alongside the hash, store what you need for display and debugging: the prefix (sk_live_) and the last 4 characters. That's how the dashboard renders sk_live_••••••••T8Rd and how a customer matches a dashboard row to the key in their env file.
3. The api_keys table
CREATE TABLE api_keys (
id TEXT PRIMARY KEY, -- key_7f3a2b9c1d (prefixed, per house style)
account_id TEXT NOT NULL REFERENCES accounts(id),
name TEXT NOT NULL, -- user-supplied: "Production backend"
key_hash BYTEA NOT NULL UNIQUE, -- sha256(full key)
key_prefix TEXT NOT NULL, -- 'sk_live_'
last4 TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('live', 'test')),
scopes TEXT[] NOT NULL DEFAULT '{read,write}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ, -- NULL = no expiry
revoked_at TIMESTAMPTZ, -- NULL = active
last_used_at TIMESTAMPTZ, -- updated async, throttled
last_used_from TEXT -- coarse: 'api', 'sdk-node/2.1', CIDR /24
);
CREATE INDEX ON api_keys (account_id) WHERE revoked_at IS NULL;
Auth path: hash the presented key, index lookup on key_hash, check revoked_at/expires_at, attach account_id + scopes to the request. One indexed read.
4. Scopes: coarse now, granular when asked
Start with read and write as per-key data (the scopes column above), enforced at the route layer. That's it. Do not build an RBAC matrix (resources × actions × roles) before customers ask for it. It's the classic pre-launch gold-plating trap: you'll guess the granularity wrong, and the permissions UI will cost more than the API itself. The schema above already stores scopes as data, so adding invoices:read-style granular scopes later (Stripe's restricted keys) is additive, not a migration.
5. Test vs. live mode
Separate namespaces (sk_test_ / sk_live_), and behind them, fully partitioned data: test-mode objects are invisible to live keys and vice versa. Stripe is the canonical model here and developers arrive pre-trained on it; a mode toggle in the dashboard that flips which partition you're viewing completes the picture.
Test mode is free, forever, no card. It is your evaluation funnel: the developer deciding whether to adopt you does it entirely in test mode. Metering or gating it is charging people to consider buying.
6. Rotation
The flow is create-new → run both → revoke-old, and your design must make the overlap window first-class. Atomic swap rotation (a "regenerate" that kills the old key instantly) causes production outages for every customer with more than one deploy target.
- Dashboard: a "Roll key" action that creates the replacement and offers to expire the old key now / in 24h / in 7 days (Stripe's roll flow offers the same shape with a couple more increments).
- API: the same as endpoints, so customers can automate rotation from their secrets manager.
POST /api_keysandDELETE /api_keys/{id}are the minimum; a dedicated/rollendpoint that does both is nicer. expires_atsupports this natively: a rolled key is just a key with an expiry.
7. Last-used tracking
Record last_used_at plus a coarse source (last_used_from: SDK user-agent, or IP truncated to /24, not a full request log). Update it asynchronously and throttled: write only if the stored value is older than ~60s, via a queue or fire-and-forget. A synchronous write per request turns your hottest read path into a write path.
This column is what makes revocation safe rather than scary: "last used 94 days ago from sdk-node" is a key you delete with confidence; without it, every stale key lives forever because nobody dares touch it.
8. Revocation and audit
- Revocation is immediate: set
revoked_at, and if you cache key lookups, bust that cache in the same operation. A revoked key that works for another 5 minutes is a security-page incident. - The confirmation dialog shows the key's name,
last_used_at, andlast_used_from. That's the information the human needs to not fat-finger production down. - Every create/roll/revoke writes an audit log entry (who, when, from where). Your customers' security reviews will ask for this verbatim.
- Register a leak-response path: when GitHub secret scanning reports a key, auto-revoke it and email the account. Auto-revoking a customer's leaked key is a feature, not an outage. Say so in the email.
9. Display-once
Show the full secret exactly once, at creation: a modal with the key, a copy button, and an explicit "you won't see this again." It is never retrievable afterward, not by support and not by an admin panel.
This isn't UX stinginess; it falls directly out of section 2. You stored a hash, so retrieval is impossible by design, and that's the selling point. "We cannot show you this key again because we don't have it" is one of the strongest sentences on your security page. Any product that can re-show a secret is storing it recoverable, and sophisticated customers know it.
Artifacts to produce
- Key format spec: the registry block from section 1 with the user's actual prefixes, committed to the repo, plus a note to apply to GitHub's secret scanning program at launch.
- The
api_keysmigration in the user's database, adapted from the sketch above. - Auth middleware: hash-lookup verification, scope check, async last-used update.
- Key generation + roll endpoints (API) and the create/roll/revoke dashboard flows, display-once modal included.
- Docs page: how to authenticate, test vs. live, rotation guidance, "what to do if you leaked a key."
Related Skills
- webhook-delivery:
whsec_endpoint secrets follow the same prefix, display-once, and rotation rules. - sdk-design: the key is the SDK's first constructor argument; format and mode errors should be caught client-side.
- security-page: hashing at rest, display-once, and secret-scanning response are headline controls to publish.
- devtool-context: create the context file first if it doesn't exist.