A scoped API key is a single opaque token a tenant mints for one end user,
bound to one account — or one wallet within it. Unlike a workspace token, it is
durable: it keeps working, and keeps its account unlocked, until the service
restarts. The end user stores the one string (for example in localStorage) and
never sees the workspace password.
POST /v1/auth/api-key
Mint a scoped key. Auth: tenant credentials. Returns 201.
Request
{
"workspace": "demo",
"password": "a-strong-workspace-password",
"account": "trading-desk",
"walletId": 0,
"tier": "unlimited",
"accountPassword": "the-accounts-own-password",
"inquiryKey": "x25519:<43 base64 chars>=",
"validationDuration": 14400
}| Field | Type | Required | Notes |
|---|---|---|---|
workspace |
string | yes | Workspace slug |
password |
string | yes | The workspace password |
account |
string | yes | The account the key is bound to |
walletId |
integer | no | Present to scope the key to one wallet; absent binds the whole account |
tier |
"basic" | "unlimited" |
no | Capability tier. Defaults to basic |
accountPassword |
string | no | Required only for a Cold Vault (own-password) account. At most 256 characters; over-length is 400 invalid_body |
inquiryKey |
string | no | The end user’s X25519 recipient (x25519:<base64>) that unlocks export. At most 128 characters; over-length is 400 invalid_body |
validationDuration |
integer | no | Seconds the inquiry key stays valid; defaults to 4 hours, max one year |
Response
{
"token": "eyJhbGciOi...",
"expiresAt": "2126-09-08T12:34:56.000Z",
"level": "wallet",
"workspace": "demo",
"account": "trading-desk",
"walletId": 0,
"tier": "unlimited",
"durable": true,
"scopes": ["read", "write", "sign", "export"],
"sensitiveEnabled": true,
"functions": ["export"],
"inquiryExpiresAt": "2026-09-08T16:34:56.000Z"
}expiresAt is far in the future because the key is durable — it is the JWT’s
own expiry, not a working lifetime. What actually ends the key is a restart or
an eviction (see Durability). inquiryExpiresAt is present only
when an inquiry key was supplied, and it lapses on its own clock even though the
key itself does not.
Responses carry cache-control: no-store.
Errors
| Status | Code |
|---|---|
| 400 | invalid_body, invalid_slug |
| 401 | bad_api_key, bad_password |
| 403 | permission_denied |
| 404 | workspace_not_found, account_not_found |
| 423 | account_locked — a Cold Vault account and no accountPassword (a wrong one is 401 bad_password) |
| 500 | storage_error — the workspace exists in the ledger but could not be opened |
| 429 | mint_rate_limited, session_capacity_exceeded, account_unlock_rate_limited |
Tiers
A key is minted at one of two fixed tiers. The tier is expressed as the key’s
scopes, and it is reported on the mint response and by whoami.
| Tier | Scopes | What a scoped key can do (this release) |
|---|---|---|
basic (default) |
read, write |
Read its bound account, wallets, and addresses |
unlimited |
read, write, sign, export |
The above, plus sealed export (with a live inquiry key) |
Export additionally requires a live inquiry key (below), so an Unlimited key without one still cannot export.
The inquiry key and export
Export is the highest-consequence operation, so it is never sealed to an operator key. The end user generates their own X25519 key pair in the browser, keeps the private half, and hands the tenant only the public inquiry key. The tenant passes it at mint; every export the key performs is sealed to it, and opens only for the holder of the matching private key.
- The inquiry key unlocks a functionality list — today just
export. - It is time-boxed by
validationDuration(default 4 hours). After it lapses, the base key keeps working but export is locked again. - Without an inquiry key,
sensitiveEnabledisfalseand export is refused.
See Export key material for the client-side unseal.
Cold Vault accounts
An account may carry its own password — an extra secret beyond the workspace
password. To mint a key for one, the tenant relays that password as
accountPassword; the service unlocks the account and the durable key keeps it
unlocked. The password is used once and never stored.
A missing accountPassword returns 423 account_locked; a wrong one returns
401 bad_password. Repeated wrong attempts back off with
429 account_unlock_rate_limited.
Durability
A scoped key is stay-exposed: while it is live it pins its workspace and its bound account unlocked. The workspace idle and absolute timeouts, and the account’s own custody timeout, do not apply to it. Everything is in memory — nothing about the key is written to disk, and a restart voids every key.
- Revocation is coarse. There is no per-key revoke.
DELETE /v1/auth/tokenis accepted for a scoped key but does nothing — a restart is what ends it. Plan to re-mint after a restart. - Capacity evicts the least-recently-used key. A durable key holds a workspace handle in memory until restart, so handle capacity is finite. When a new key would exceed a tenant’s capacity, the tenant’s least-recently-used durable key is evicted to admit it. An evicted key’s next request fails exactly as after a restart — re-mint it. Eviction is recorded in the audit log.
GET /v1/auth/whoami
Report what the calling token can do. Auth: any token. Returns 200.
{
"level": "wallet",
"workspace": "demo",
"account": "trading-desk",
"walletId": 0,
"tier": "unlimited",
"durable": true,
"scopes": ["read", "write", "sign", "export"],
"sensitiveEnabled": true,
"functions": ["export"],
"inquiryExpiresAt": "2026-09-08T16:34:56.000Z",
"expiresAt": "2126-09-08T12:34:56.000Z"
}tier is present only for a scoped (account- or wallet-level) key; a plain
workspace token has no tier. functions is what the inquiry key currently
unlocks — empty once it has lapsed. A holder that has forgotten how its key was
minted can read all of this back at any time.
Related
- Scoped API keys — the durable-key model behind these routes
- Scopes and tiers — the capability model
- Export key material — the client-unseal flow
- Workspaces and tokens — the session model