Skip to content

Mint, rotate and revoke a virtual key

A virtual key is what an application presents instead of a provider’s own credential. It is an identity and nothing else: a name, some metadata, an expiry, and the name of a profile that carries what it may do.

Keys are replicated state, not configuration. There is no keys: block in the config document and there never was. A key is minted through the admin API, lands in the raft log, and is on every node before the call returns.

A key is minted once, serves, and then stops in one of four ways; rotation is an overlap on one profile, and mint is marked as the only moment the token exists

Run this command:

Terminal window
$ pistra admin POST /admin/v1/keys \
-d '{"name":"checkout-api","profile":"payments","metadata":{"team":"payments"}}'

The response looks like this:

{"name":"checkout-api",
"token":"pistra_...",
"created_at":"2026-09-01T10:11:25.451701Z"}

The token is in that response and nowhere else. Only its SHA-256 is stored, so nothing, not the admin API, not the console, not a raft snapshot, not you, can produce it again. A lost token is a rotation.

name is the only required field. profile may be omitted, which means no profile: unrestricted, but for the deployment-wide rules. A profile that does not exist is refused with 400 and a list of the ones that do, because the mistake is nearly always a spelling of something real.

metadata is free-form and is what CEL reads as key.metadata:

access_rules:
- name: eu-only
condition: '!has(key.metadata.region) || key.metadata.region != "eu"'
action: deny

The has() guard is not decoration. Reading key.metadata.region on a key that was minted without one is an error, not a false. A rule that omits the guard refuses every key that predates the label rather than ignoring them.

Metadata is fixed at mint. Changing it is a rotation too. Put slow-moving facts in it, such as a team or a service name, and leave anything that changes to the profile.

The client presents it as Authorization: Bearer or as x-api-key, so an OpenAI-style and an Anthropic-style SDK both work with no special configuration.

The layout is deliberate: pistra_, then 43 base62 characters of entropy, then 6 more that are a CRC-32 over them.

The prefix is the product’s whole name rather than an abbreviation, because an abbreviation is not identifiable. Two letters and a hyphen match locale tags and ordinary identifiers, and a scanner that fires on those is a scanner nobody keeps switched on. The checksum lets a scanner confirm a candidate on its own, without asking your gateway whether a string it found in a public repository is real.

Your own deployment is one of those scanners. PISTRA_KEY is a built-in credential recognizer, so a key pasted into a prompt is found in traffic like any other credential. See the Credential coverage.

They are not interchangeable. Three are somebody’s decision and one is a mistake.

what happened the client sees pistra_auth_failures_total reason how it ends
expires_at passed 401 invalid_api_key expired_key it does not; mint a new key
suspended 403 key_suspended, naming when it lifts key_suspended the lease lapses, by itself
revoked 401 invalid_api_key revoked_key never, it is permanent
its profile was deleted 403 unknown_profile, naming the profile unknown_profile put the profile back, or revoke the keys

Both 401s say exactly what an unrecognized token says. A prober must not be able to tell a key that never existed from one somebody revoked this morning. The difference lives in the audit trail and in the counters. There it is useful, and the caller cannot read it.

The two 403s are not folded in with them, because the credential is real and the gateway knows whose it is. Answering “invalid API key” would send somebody looking for a lost token when the problem is a profile an operator removed, or a suspension somebody else opened an incident for.

Run this command:

Terminal window
$ pistra admin DELETE /admin/v1/keys/checkout-api

In-flight streams finish. The next request is refused. Revocation is final, and there is no un-revoke. The record is a tombstone rather than a deletion, so a revoked key stays in the list and its name is never reusable.

To stop a key serving right now when you are not yet sure it is compromised, suspend it rather than revoke it. A suspension is a lease with an expiry, on the key or on its whole profile, that ends itself if nobody comes back to it. See Suspend a key, provider or rule.

There is no rotate verb, and there is no way to attach a second token to one key. Rotation is three steps with an overlap in the middle. The overlap lets it happen without an outage:

Terminal window
$ pistra admin POST /admin/v1/keys \
-d '{"name":"checkout-api-2","profile":"payments","metadata":{"team":"payments"}}'
# deploy the new token to the client, and wait for every instance to have it
$ pistra admin DELETE /admin/v1/keys/checkout-api

Nothing about the two keys is linked except that they name the same profile. The shared profile is what makes the overlap work. Both are live, both get the same policy, and the second one is doing real traffic before the first one stops.

Three things about this are worth knowing before the first time.

The replacement needs a different name. A revoked key keeps its name forever, so re-minting checkout-api is a 409. Pick a naming convention that admits a successor, such as a date or a counter, before the first rotation.

A rotated key starts with a full budget allowance. A non-shared budget, which is the default, gives each key its own bucket, and that bucket is keyed by the key’s name. A new name is a new bucket, so a team that rotates mid-window gets its allowance again for the rest of that window. If that matters, set shared: true on the budget and the whole profile draws from one bucket that rotation does not reset. See Cap what a team spends.

Nothing reminds you to finish. Both keys work. That is the point of the overlap, and it is also why the last step gets forgotten. A half-finished rotation looks like a working deployment. The key list below is the only thing that will tell you.

expires_at turns the last step into something that happens whether anybody remembers or not:

Terminal window
$ pistra admin POST /admin/v1/keys \
-d '{"name":"ci-2026-q4","profile":"ci","expires_at":"2027-01-01T00:00:00Z"}'

An expiry that nobody replaced is an outage on a date. The alternative is a credential that outlives the service it was minted for. Which of those you would rather have is a decision, but only one of them is visible in advance. Watch pistra_auth_failures_total{reason="expired_key"}. It goes from zero to non-zero at the moment somebody needed to have known.

Run this command:

Terminal window
$ pistra admin /admin/v1/keys

The response looks like this:

{"keys":[
{"name":"checkout-api","profile":"payments","revoked":true},
{"name":"checkout-api-2","profile":"payments","metadata":{"team":"payments"}},
{"name":"orphan","profile":"deleted-last-week","unknown_profile":true}
]}

Revoked and expired keys are listed on purpose. A key that vanished on revocation would leave revoked_key refusals in the trail with nothing to attribute them to. Grep for unknown_profile. Those keys are already failing, and an operator reading a healthy-looking list would have no way to know.

The trail records refusals, not admissions. A record of every admitted request is the traffic log, and duplicating it would make the trail something nobody could verify the completeness of. An expired, revoked or orphaned key produces a request.auth record naming the key and the reason and nothing about the token. A suspended one produces a request.suspended record instead, because nothing was wrong with the caller. The deployment was deliberately not serving.

Minting and revoking are themselves admin-plane decisions, subject to Cedar like every other one. createKey and revokeKey are separate actions, so a CI pipeline can be allowed to mint without being allowed to revoke. See Restrict what an admin caller can do.