Minting is the only workspace-tier operation authenticated by tenant credentials — it is where the workspace password is presented.
POST /v1/auth/token
Mint a workspace token. Auth: tenant credentials. Returns 201.
Request
{
"workspace": "demo",
"password": "a-strong-workspace-password",
"scopes": ["read", "sign"]
}| Field | Type | Required | Notes |
|---|---|---|---|
workspace |
string | yes | Workspace slug |
password |
string | yes | At least 1 character |
scopes |
string[] | no | Defaults to ["read","write","sign"]. 1–4 entries, each ≤32 chars |
Only read, write, sign, and export are grantable. An unrecognised name
returns 400 with invalid_body — unknown scopes are never silently dropped.
A workspace token can hold export, but export itself needs a scoped API key
carrying an inquiry key, so a workspace token can never actually reach the
export routes.
Response
{
"token": "eyJhbGciOi...",
"expiresAt": "2026-08-18T12:34:56.000Z",
"workspace": "demo",
"scopes": ["read", "sign"]
}Errors
| Status | Code |
|---|---|
| 400 | invalid_body, invalid_slug |
| 401 | bad_api_key, bad_password |
| 403 | permission_denied |
| 404 | workspace_not_found |
| 429 | mint_rate_limited, session_capacity_exceeded |
| 500 | storage_error — the workspace exists in the ledger but could not be opened |
POST /v1/auth/token/refresh
Issue a new token and push out the idle clock. Auth: workspace token, any
scope. Returns 200.
Request
The body must be empty: no body at all or a literal {}. A body carrying any field returns 400 with invalid_body.
Response
{
"token": "eyJhbGciOi...",
"expiresAt": "2026-08-18T12:49:56.000Z",
"sessionExpiresAt": "2026-08-18T20:15:00.000Z",
"workspace": "demo",
"scopes": ["read", "sign"]
}sessionExpiresAt is the absolute ceiling. Refresh moves expiresAt; it never
moves sessionExpiresAt. Once the ceiling passes, mint a new token instead.
Each refresh returns a new token — replace the one you hold. Responses carry
cache-control: no-store.
Refreshing does not invalidate the token you sent. It extends the same lease
and issues a fresh JWT for it; the older one keeps working until its own
expiresAt passes. If a token has leaked, refreshing does nothing to contain
it — revoke it.
The new token names the same lease as the one it came from — refresh extends a lease, it never adds one. So revoking either token ends both: a revoke sent with the pre-refresh token kills the refreshed one too. That is the behaviour you want after a leak, and it is why refresh is not a way to rotate away from a compromised token.
Errors
| Status | Code |
|---|---|
| 400 | invalid_body |
| 401 | session_expired |
DELETE /v1/auth/token
Revoke the lease this token names. Auth: workspace token, any scope.
Returns 204 with no body. The workspace locks only once its last lease is
gone — see below.
curl -X DELETE "$API_URL/auth/token" -H "Authorization: Bearer $TOKEN"Related
- Authenticate — the lifecycle as a task
- Workspaces and tokens — the model
whoami— introspect what the calling token can do (documented with API keys, and accepted for any token)