API Keys & Scopes
Admin API keys and scoped agents for CLI, CI, and automation, with least-privilege scopes and no-escalation delegation.
Not everything that talks to Token Vault is a person at a console. Scripts, CI jobs,
and automation need their own identity, with only the permissions they need. Token Vault
gives you three non-human principals, all sent as Authorization: Bearer.
| Principal | Key prefix | Powers |
|---|---|---|
| Human | Firebase sign-in (console or tvault login) | Every scope, plus the human-only actions below |
| API key | tvkey_… | The scopes you pick when you create it. Built for CLI, CI, and automation |
| Classic agent | tvagent_… | credentials:read + mcp:use, bounded by its grants. Unchanged |
| Scoped agent | tvagent_… | An agent created with a scope list, so it can do more than read credentials |
Credentials never pass through Token Vault
Scopes govern the control plane. When a key writes a credential with tokens:create, the value
goes straight from the CLI to your webhook on a signed store ticket. Token Vault never sees it.
Create an API key
tvault keys create --name ci --scopes tokens:create-read,agents:create --expires 90d
# tvkey_... <- printed to stdout once# Same thing over HTTP (human sign-in within the last 5 minutes required)
curl -X POST https://api.tokenvault.uk/api/keys \
-H "Authorization: Bearer $FIREBASE_ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"ci","scopes":["tokens:create-read","agents:create"],"expiresAt":"2027-01-05T00:00:00Z"}'Shown once
The tvkey_… value is returned a single time and is never retrievable. If you lose it, rotate the
key (tvault keys rotate <id>, as a signed-in human) and update wherever it was stored. A key that
is still working can rotate itself with tvault keys rotate --self.
Expiry is 30, 90, or 365 days, a custom date, or never. --expires 90d, --expires 2027-01-05,
or --expires never. Prefer a short expiry for anything that runs in CI.
Manage keys
tvault keys ls # name, scopes, status, expiry, last used
tvault keys show <id> # one key, plus its grants
tvault keys rotate <id> # new tvkey_ value; the old one stops working
tvault keys revoke <id> # revoke this key only (needs keys:revoke); keys it created are not affected
tvault keys grant <id> github # give the key read access to a token
tvault keys ungrant <id> githubCheck who you are at any time:
tvault whoami # principal, kind, scopes, expiryScoped agents
An agent is a classic agent unless you give it scopes. A scoped agent keeps its tvagent_… key and
grants, and gains control-plane powers on top.
tvault agents create --name hub-minter --kind scoped --scopes tokens:create
tvault agents rotate-key hub-minter # lost the key? rotate itSee the scope reference for all 18 scopes.
Every key stands alone
A key or scoped agent that can create other principals can only hand down what it has, and only at the moment it creates them.
- No escalation at creation. A child gets a subset of the creator's scopes and grants. A child key's expiry is at most the creator's expiry at that moment.
- Some scopes only a human can hand out.
keys:manage,keys:revoke,agents:manage,agents:delete,policies:write,grants:write,tokens:update,tokens:delete, andproxies:writecan only be put on a key or agent by a signed-in human, in the console or CLI. A key that tries gets403 SCOPE_NOT_DELEGABLE, even if it holds the scope itself. - Independent afterwards. Once created, a key or agent is decided by its own record only: its own status, expiry, scopes, grants, and attached policies. Revoking, suspending, deleting, narrowing scopes, removing grants, or attaching a policy or rate limit to one key never affects another, including keys it created.
- Revoking a key does not revoke what it created. If a key is compromised, revoke it, then use the "Created by" column to find the keys and agents it made and revoke those yourself.
- Scopes cover the whole account. A scope means the same thing whoever holds it.
agents:manageworks on any agent,tokens:deleteon any token, whether or not that key created it. "Created by" is information only and grants no reach. - Delete is separate from manage.
agents:manageedits, suspends and resumes agents.agents:deletedeletes them.keys:managecreates, lists, edits, suspends and resumes keys.keys:revokerevokes them. - Suspend and resume are symmetric. Anything with
agents:manageorkeys:managecan resume another principal, including one a human suspended. It can never resume itself. - A key or agent can't loosen its own controls. It can't change its own scopes, turn its own MCP back on, resume itself, edit, detach or delete policies attached to itself, or change its own grants (
403 SELF_CHANGE_FORBIDDEN). It can rotate its own key (--self), suspend itself, rename itself, and add a policy to itself. A human can do all of these. - Narrowing is always fine. The
SCOPE_NOT_DELEGABLEcheck applies only to human-grant-only scopes being newly added. Removing scopes from a key that already has them is allowed. - Proxies. Changing a webhook proxy's upstream URL, headers or service needs
credentials:readand a grant on that service, the same as creating one. Changing a session (tv_session) proxy's upstream is human-only. Only a human, or a scoped agent binding itself, can create a session proxy. - Auto read grants can be passed on, but stay tied to one creation of the token. The read grant a key gets from
tokens:create-readcan be passed on withgrants:write(copies of copies too) and used to bind a proxy. Every token creation gets a new creation id: a fresh create, a key re-claiming its own name with a new ticket, and every take-over. The grant, every copy, and every proxy bound from one work only while the token is still on the creation they were made under. Re-creating, overwriting, OAuth-connecting or deleting the token, or binding or rebinding the webhook, lapses them, and once lapsed they never come back, even if the same key becomes the creator again. The one write that keeps them alive is the creating key or agent overwriting its own token. If a webhook bind or rebind can't reset them, the bind is refused (503 TOKEN_OWNERSHIP_UNAVAILABLE). Without a live grant of its own, a key gets403 GRANT_REQUIREDwhen it tries to pass a token on or bind a proxy. - Auto and regular grants don't replace each other silently. A key or agent can't replace a target's regular grant with an auto-derived copy (
409 MANUAL_GRANT_IN_PLACE), or the reverse (409 AUTO_GRANT_IN_PLACE). A human can replace either. - Limits. Token Vault can't see a value changed directly at your webhook, so that doesn't lapse anything. A credential ticket already minted (valid 60 seconds) can still be redeemed after the value changes.
- Editing a proxy as a human clears its tie. When you change a proxy's upstream URL, headers or service, it no longer lapses if the token's creator changes.
grants:writealso needscredentials:read. A key can only hand out access to tokens it can read. A grant a key passes on can't outlive the key's own grant.- Changing a proxy's upstream URL needs new headers. A key that changes the upstream URL must send the headers in the same edit (
400 HEADERS_REQUIRED). - Deleting a token removes every grant on it. A key can't re-create a token name that still has proxies bound to it (
409 TOKEN_OWNED). - Folder grants. A proxy bound from a folder grant is gated by that folder's policies.
- Policies copied at creation. A key or agent created by a key starts with copies of its creator's directly attached policies. This happens at creation only; later changes to either side don't propagate.
- Self-change covers proxies. "Can't loosen its own controls" includes policies on proxies the key created.
- Dry runs are free.
POST /api/policies/evaluatenever consumes rate-limit or usage counters and writes no audit events, for anyone withpolicies:read. - Rotating keys. Any key or agent can rotate its own key with no extra scope:
tvault keys rotate --selfortvault agents rotate-key --self(the CLI switches your stored context to the new key and prints it once). Rotating or revealing another principal's key is human-only (403 HUMAN_ONLY). If two rotations race, the loser gets409 ROTATION_CONFLICTand no new key is issued.
# ci can mint a child key, but only with scopes ci itself holds
tvault keys create --name ci-child --scopes tokens:create --expires 30dHuman-only actions
No key, of any scope, can do these. They require a signed-in human.
| Action | Why |
|---|---|
| Unlock the vault | A person decides when automation may run |
| Bind or rebind the webhook | Moves the data plane |
| Delete the vault | Irreversible |
| Add MCP redirect hosts | Changes where OAuth tokens can be sent |
| Delete or export the account | Account-level |
| Rotate or reveal another principal's key | A key may only rotate its own |
| Grant the scopes listed above to a key or agent | A person decides who gets destructive powers |
A key that tries gets 403 HUMAN_ONLY.
Locked vault freezes every key
When the vault is locked, every API key and scoped agent stops working until a human unlocks it. This is the same kill switch as Stop All Access.
Policies
Attach access policies to an API key with entityType: "api_key":
curl -X POST https://api.tokenvault.uk/api/policies/$POLICY_ID/attach \
-H "Authorization: Bearer $FIREBASE_ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"attachments":[{"entityType":"api_key","entityId":"'"$KEY_ID"'"}]}'Detach uses the same body shape at /api/policies/$POLICY_ID/detach.
Upgrading
- Deleting a token now removes every grant on it and drops it from folders, so re-creating the same name does not restore anyone's access. Grant access again after re-creating.
- Token delete needs Firestore to clean up, so it returns
503if Firestore is unavailable. Nothing is deleted; retry. - Rebinding the webhook lapses every
tokens:create-readgrant (and copies and proxies bound from them). Grant access again after a rebind.
Worked example: store a Cloudflare token from a script
A script on your mcp-hub mints a Cloudflare service token and saves it. It needs tokens:create
and nothing else, so it can write a token but never read one back.
# One-time, as a human
tvault agents create --name cf-minter --kind scoped --scopes tokens:create
# tvagent_... <- shown once; store it in the hub's secret store
# In the hub script
printf %s "$CF_MINTER_KEY" | tvault login --key-stdin --as cf-minter
CF_TOKEN=$(./mint-cloudflare-service-token.sh) # your minting step
tvault tokens create --service cloudflare-zt --value "$CF_TOKEN"The value goes from the CLI to your webhook on a signed store ticket. If you want the script to read
it back immediately, give it tokens:create-read (which grants the creator a read grant at creation time)
and credentials:read (which is what allows the read itself).
Pipe keys in with --key-stdin rather than passing --key on the command line, where it would land in
shell history and process listings.
Errors
Errors return {"detail":{"code","message","missingScope"}}. See scopes, errors and exit codes.