The Associate course taught the basics: keys live in environment variables, never in a repository or a browser bundle, and a small proxy stands between your front end and the API. That is enough for one service run by one person. It stops being enough the day you have a staging environment, a nightly job, a contractor and a customer-facing widget, all spending the same credits.
This lesson treats keys as infrastructure. You will build an inventory where every key has one owner and one job, inject keys from a secrets manager at runtime, narrow each key to what its job needs, rotate without dropping a request, and run a leaked-key incident from detection to cleanup.
One key per consumer, and an inventory to prove it
APIVerve gives you two kinds of credential. The primary key belongs to the account and is the only one that can be rotated or given an expiry. Sub-keys are additional credentials on the same account: they reach the same catalog, spend the same credits, and each one can carry its own restrictions and be deleted without touching anything else. Every sub-key also gets its own rate-limit bucket, so a bulk job on one sub-key cannot eat the per-minute window your interactive traffic depends on.
The rule that holds up in practice is one key per thing that could independently need revoking:
- Per environment. Production, staging and each developer's machine. A staging key ends up in a CI log or a screenshot far more often than a production one.
- Per job. The nightly enrichment run, the bulk import, the scheduled report. These are the workloads that loop when something breaks, and a loop is much easier to stop when it has its own credential.
- Per third party. A contractor, an agency, a customer's server. Anything running on a machine you do not administer.
What does not need its own key: three services inside one deployment that ship together and share a blast radius. A key per microservice sounds tidy and mostly buys you rotation work.
Keep the primary key in exactly one place you control, ideally unused by day-to-day traffic, and put every consumer on a sub-key. That arrangement is what makes the rest of this lesson possible: rotation by overlap, revocation of one consumer, per-job rate caps.
The two kinds are easy to tell apart. Sub-keys carry a distinct prefix (apv_sbk...) while the primary key starts apv_. A service that should only ever hold a sub-key can assert that at startup, which catches the day someone pastes the primary key into the wrong secret:
// Node 18+, ESM. Fail at startup, not on the first request.
export function loadApiKey({ requireSubKey = true } = {}) {
const raw = process.env.APIVERVE_API_KEY;
if (!raw) throw new Error('APIVERVE_API_KEY is not set');
// Secrets written with `echo` often carry a trailing newline, and fetch
// refuses a header value containing one.
const key = raw.trim();
if (!key.startsWith('apv_')) throw new Error('APIVERVE_API_KEY does not look like an APIVerve key');
if (requireSubKey && !key.startsWith('apv_sbk')) {
throw new Error('This service must run on a sub-key, not the primary key');
}
return key;
}The inventory
An inventory is a short, committed, non-secret file that answers the questions you will be asked during an incident: which keys exist, who owns each one, where it is stored, what it can reach, and how to tell its log lines apart. Never write the key into it. Write a fingerprint instead: the first few hex characters of a SHA-256 of the key. A fingerprint is enough to match a key to its inventory row and useless to an attacker.
[
{ "name": "orders-api prod", "kind": "sub-key", "fingerprint": "3f9a2c1e", "owner": "payments team",
"stored": "gcp:projects/acme/secrets/apiverve-orders-prod", "blocked": "all except emailvalidator, phonenumbervalidator",
"ip": "203.0.113.7/32", "rateCap": 60 },
{ "name": "nightly enrichment", "kind": "sub-key", "fingerprint": "b71d04aa", "owner": "data team",
"stored": "github:actions/APIVERVE_NIGHTLY", "blocked": "all except countrylookup", "ip": null, "rateCap": 20 }
]
Compute fingerprints locally, never by sending a real key anywhere, including to a hashing API. The function is two lines:
import { createHash } from 'node:crypto';
export const fingerprint = (key) => createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 8);To see what that digest looks like, and to check your local function against an independent implementation, hash a made-up value with the Hash Generator API. The first eight characters of data.hashes.sha256 are the fingerprint your inventory would record:
curl "https://api.apiverve.com/v1/hashgenerator?text=apv_sbk_EXAMPLE-not-a-real-key&algorithms=sha256" \
-H "x-api-key: YOUR_API_KEY"A made-up value. Real keys are fingerprinted locally and never sent anywhere.
Your inventory needs a way to match a log line or a leaked string to a specific key. What should it record?
Inject keys at runtime from a secrets manager
Environment variables are how the key reaches your process. They are not where it should be stored. In production the source of truth is a secrets manager (Google Secret Manager, AWS Secrets Manager, HashiCorp Vault, Doppler, or your hosting platform's encrypted secret settings), and the key arrives in the process at deploy or start time. Three rules make that safe:
- Never bake a key into an artifact. A key in a Docker image layer, a compiled bundle or a deployment ZIP is copied into every registry, cache and laptop that pulls it. Inject at runtime instead.
- Give each workload read access to its own secret only. The orders service's service account can read
apiverve-orders-prodand nothing else. If the orders service is compromised, the nightly job's key is not. - Keep the value out of diagnostics. Crash reporters, debug endpoints and "print the config" startup logs love to dump
process.env. Make sure yours do not, or that they redact anything that looks like a credential.
Most platforms can map a secret straight to an environment variable, and then loadApiKey() above is all the code you need. When you need to read it yourself (to pick up a rotated value without a restart, for instance), fetch it from the manager and cache it briefly:
// npm install @google-cloud/secret-manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();
const NAME = 'projects/acme/secrets/apiverve-orders-prod/versions/latest';
let cached = null;
export async function apiKey() {
if (cached && Date.now() - cached.at < 5 * 60_000) return cached.value;
const [version] = await client.accessSecretVersion({ name: NAME });
const value = version.payload.data.toString('utf8').trim();
cached = { value, at: Date.now() };
return value;
}// npm install @aws-sdk/client-secrets-manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({});
let cached = null;
export async function apiKey() {
if (cached && Date.now() - cached.at < 5 * 60_000) return cached.value;
const out = await client.send(new GetSecretValueCommand({ SecretId: 'apiverve/orders-prod' }));
const value = out.SecretString.trim();
cached = { value, at: Date.now() };
return value;
}The five-minute cache is a deliberate trade. It keeps the secrets manager off your hot path, and it means a rotated value reaches every running instance within five minutes without a redeploy. If your calls start returning 401, drop the cache and re-read once before failing; that turns a just-rotated key into a single slow request instead of an outage.
Scope every key to its job
A key with no restrictions can call every endpoint in the catalog, from anywhere, at your plan's full rate. Key scoping narrows a key without issuing a new one. The restrictions are set per key, primary or sub-key, under Edit restrictions on the dashboard's API keys page:
| Restriction | What it does | Plan | When it fires |
|---|---|---|---|
| Block specific APIs | The key cannot call the endpoints you name | Pro and above | 403 Access to <api> is blocked for this API key |
| Block VerveKit tools | Turns off JSON bins, mock endpoints, forms or GraphQL for the key | Pro and above | 403 |
| IP allow-list | Only listed IPv4 addresses or CIDR ranges may use the key | Mega | 403 IP address not allowed for this API key |
| Custom rate limit | Caps the key below the plan's per-minute ceiling | Mega | 429 |
A refused call is rejected before it runs and costs nothing. Four details decide whether scoping actually protects you:
Blocking is a deny-list. You name what the key may not reach, and everything else stays reachable, including endpoints that ship after you scoped it. Review the block list of every narrow key when you add capability, and treat GraphQL as its own item: one GraphQL request can reach several endpoints, so a key you scoped at the REST level should have GraphQL blocked unless it needs it. Turning off every VerveKit tool is stored as a wildcard, so that switch keeps meaning "none" as tools are added.
Restrictions do not inherit. Each key's deny-list is its own. Tightening the primary key does nothing to a sub-key issued last month.
IP allow-lists break deployments in predictable ways. Serverless platforms and CI runners egress from pools that change, so pin to a NAT gateway or static egress address you control. Ranges are matched as IPv4, so a host that egresses over IPv6 is refused rather than falling back. And a test call from your desk proves nothing about your production egress address. Roll an allow-list out on a new sub-key, watch it, then switch traffic.
Nothing here bounds spend. A rate cap decides how fast a key can spend, not how much. Every key draws on the account's credits, and when the account runs out, every key on it gets 429 at the same moment. If a consumer must not spend past a point, that limit lives in your code (the scale course covers credit budgets in depth).
The diagnostic payoff is that the status tells you which system to look at. A 401 means the credential itself is not usable: missing, unrecognised, deleted, expired or suspended. A 403 means the credential is fine and a restriction refused this call. Treat a sudden 403 after a deploy as "the new egress address is not on the allow-list" before you suspect anything else.
A sub-key blocked down to Email Validator works in staging. In production, behind a new NAT gateway, every call returns 403 with IP address not allowed for this API key. What is the fix?
Rotate without dropping a request
Rotating the primary key is instant and has no grace window: the moment you confirm, the old value gets 401 everywhere. Credits, analytics, restrictions and sub-keys survive (sub-keys are repointed at the new key just after the rotation, so make one call with each afterwards), but anything still holding the old string is down until it is redeployed. That is acceptable for a single deployment you can update in seconds. It is not acceptable for a fleet, a mobile app or a customer's integration.
Sub-keys cannot be rotated at all. You replace them, and replacement is what makes a zero-downtime rotation possible:
- Create a new sub-key for the same consumer with identical restrictions. Copy the block list and allow-list deliberately; they do not come across on their own.
- Publish it as a new version of the consumer's secret. With the cached reader above, running instances pick it up within the cache window. With platform-injected variables, redeploy or restart.
- Watch the call log until the old key's traffic stops. Both keys are live; nothing fails during this step.
- Delete the old sub-key. Any straggler gets
401on its next request, which is the signal you wanted: something was still holding it.
The order matters and is worth automating: the new key exists and is deployed before the old one disappears. For a planned rotation this costs nothing. The only time you invert it is a leak, covered next.
Expiration is the other half of rotation policy, and it has a sharp edge. An expiry (7 days to 1 year, paid plans) can be set only when the primary key is issued, in the rotate dialog. Sub-keys have no expiry, and calls made with a sub-key are checked against the primary key, so when the primary key expires every sub-key on the account starts returning 401 with API key has expired. Please rotate your key in the dashboard. Nothing warns you as the date approaches. Use an expiry for accounts that should die (a demo tenant, an evaluation), not as a reminder to rotate production, and if you do set one, monitor for that exact error sentence so an expiry reads as an expiry rather than a mystery outage.
Revoke fast: running a leaked-key incident
Assume a key will leak: a .env in a public gist, a key pasted into a support chat, a screenshot of a terminal. What separates a bad afternoon from a bad month is how fast you can answer three questions: which key, what can it reach, and what has it already spent.
Detect. The signals arrive from several places. A secret-scanning alert on a repository. The dashboard's 80% usage warning, which is on by default; leave it on. An endpoint you never call appearing in the per-API breakdown in analytics. A credit-burn alert you build in lesson 3. Your fingerprint lets you map a leaked string to an inventory row in seconds.
Contain. Pick the lever that matches what leaked:
| Situation | Lever | What the holder sees |
|---|---|---|
| A sub-key leaked | Delete it | 401 on the next request; cannot be undone |
| You need to pause a consumer, not end it | Block every API on that key | 403; the credential stays valid |
| The primary key leaked | Rotate it now | 401 everywhere the old value lives |
For a leak, do not wait for a quiet moment and do not stage the rotation. Rotate or delete first, accept the minutes of downtime, then redeploy. Downtime is recoverable; an unbounded credential on your balance is not. This is exactly where one key per consumer pays off: deleting one sub-key stops one consumer, and everything else keeps running.
Assess. Analytics shows usage per day and per endpoint, so a step change in volume or an unfamiliar endpoint tells you when the key was used and for what. On Mega, the audit history records rotations and sub-key changes with who made them.
Clean up. Delete the gist, edit the ticket, purge the commit. This is cleanup, not containment: the copy still exists in every clone and cache, and only the revocation made it worthless.
Fix the cause. Write down why the key was where it was, and change the system rather than the person: a pre-commit secret scanner, a sub-key for the consumer that had the primary key, an IP allow-list for the server that should have had one.
A sub-key used by a contractor appears in a public repository at 14:05. The contractor still needs access next week. What do you do first?
Keys never reach a browser, even indirectly
The Associate proxy kept the key on the server and allow-listed which APIs and parameters the browser could ask for. In production, the proxy is now the thing an attacker will call, so it needs the controls an API of your own would have:
- Authenticate the caller. A signed-in session or your own token, checked before anything is forwarded. An anonymous proxy is a free API on your credits.
- Rate-limit per user, not just per IP. Shared IPs (offices, mobile carriers) make per-IP limits either too loose or too strict.
- Give the proxy its own sub-key, blocked down to exactly the APIs the proxy forwards and, on Mega, rate-capped below your ceiling. If the proxy's allow-list ever has a bug, the key's deny-list is the second wall.
- Return only the body. Upstream headers carry your plan and credit balance; your users do not need them.
For a simple lookup widget there is an alternative to running a proxy at all: APIVerve's embedded forms authenticate with a separate form token, proxy server-side, and support a domain allow-list and a CAPTCHA, so no API key appears on the page. The form token rotates independently of your API key.
If a key genuinely must be public (a demo, an internal tool on a trusted network), accept that deliberately and bound it: a dedicated sub-key, blocked down to one API, rate-capped, and replaced on a schedule.
What you have now
An inventory where every key has one owner, one job and a fingerprint; keys injected from a secrets manager and re-read without a restart; scoping that turns a leak into a small problem; a rotation you can run without dropping a request; and an incident sequence that starts with revocation. Keys are the credentials you hold. The next lesson covers the credentials other services use to prove a request came from them: Verify webhook signatures with HMAC.