---
title: "Virtual keys"
description: "How ob_live_ and ob_test_ keys authenticate agent traffic to Obol, how they are scoped, and how revocation propagates to the gateway."
---
An Obol virtual key is the credential your agent presents to Obol. It is minted by
Obol, stored by Obol as a hash, and checked by Obol. It never authenticates to a
vendor.
**Inbound credential ≠ outbound credential.** An `ob_live_…` key authenticates
to Obol; the customer's vaulted vendor credentials authenticate to vendors. The
two never meet. See [/security/credential-brokering](/security/credential-brokering)
for the outbound half.
## Key format
A key is a prefix plus 43 base64url characters encoding 32 random bytes
(`apps/gateway/crates/obol-auth/src/key.rs`):
```text
ob_live_<43 base64url chars>
ob_test_<43 base64url chars>
```
The prefix carries the environment. `ob_test_` cannot be minted for a `prod`
workspace, and the gateway independently refuses a test key against a prod
workspace through `obol_auth::env_allows`.
Control stores only `sha256(plaintext)` and the last four characters. The
plaintext is returned exactly once, in the mint response, and is never stored,
logged, echoed, or re-served (`apps/control/app/api/keys.py`,
`apps/control/app/services/keys.py`). In Rust, `RawKey` holds the plaintext in a
`SecretString`, redacts its `Debug`, and compares in constant time.
## Minting, listing, revoking
The three routes live in `apps/control/app/api/keys.py`:
| Route | Operator action required |
|---|---|
| `POST /workspaces/{workspace_id}/keys` | `key.manage` |
| `GET /workspaces/{workspace_id}/keys` | `workspace.read` |
| `POST /workspaces/{workspace_id}/keys/{key_id}/revoke` | `key.manage` |
Every route requires a verified operator session and a membership row for the
workspace in the path (ADR-0033); see
[/concepts/tenancy-and-identity](/concepts/tenancy-and-identity). Every mutation
writes an `audit_log` row in the same transaction as the act, so a key that
exists always has a row naming who created it. Audit details carry `agent_id`,
`prefix`, `last4`, `tools_mode`, and `scopes` — never secret material.
Request and response shapes are in [/api-reference/keys](/api-reference/keys).
## What a key carries
A key's `KeyContext` (`apps/gateway/crates/obol-types/src/key.rs`) is what the
gateway resolves and what policy evaluates against:
| Field | Meaning |
|---|---|
| `key_id`, `workspace_id`, `org_id` | Identity and tenancy |
| `env`, `prefix` | Environment binding |
| `status` | `active`, `revoked`, `expired` |
| `agent_id` | Cedar principal `Obol::Agent::""` |
| `allowed_models` | Glob patterns: exact, `prefix*`, `*suffix`, `*` |
| `allowed_tools` | Namespaced tool patterns, same grammar (`stripe.*`) |
| `tools_mode` | `full` or `progressive` |
| `budget_micros_usd_month`, `rpm`, `tpm` | Spend and rate ceilings |
| `scopes` | Route families this key may use; defaults to `["mcp", "llm"]` |
| `expires_at` | Optional expiry |
| `attributes` | Extra principal attributes exposed to Cedar |
`allowed_tools` and `allowed_models` are enforced as CEL visibility
(`obol-policy/src/cel/mod.rs`), not as a hand-rolled check; Cedar then decides
permission. See [/policy/cel-prefilter](/policy/cel-prefilter) and
[/policy/cedar](/policy/cedar).
## Scopes
`Principal::has_scope` gates route families. A virtual key is bound by
`KeyContext.scopes` alone. An MCP OAuth access token is an MCP-only credential:
it satisfies a scope check only when the required scope is `mcp` *and* `mcp`
appears in both the bound key's scopes and the token's whitespace-delimited
`scope` claim (`apps/gateway/crates/obol-auth/src/lib.rs`).
A scope failure answers `policy_denied` with `insufficient scope`; an
environment mismatch answers `env_mismatch`; a frozen workspace answers
`workspace_frozen` (`apps/gateway/crates/obol-gateway/src/routes/mod.rs`).
## How the gateway resolves a key
1. `credential_of` splits the `Authorization` header. `Bearer ` (scheme
case-insensitive, exactly one space) and a bare token are both accepted.
2. A three-segment value that does not start with `ob_` is parsed as a JWT;
everything else is parsed as a raw key.
3. The gateway looks the key up by SHA-256 hash — first in the Redis key cache,
then falling back to control's `GET /internal/v1/keys/{key_hash}`, filling the
cache with a TTL (`OBOL_KEY_TTL_S`, default 60 seconds).
4. `check_usable` rejects revoked before expired, and treats
`expires_at <= now` as expired.
A store failure during lookup maps to `NotReady`, never to `Unauthorized`. The
lookup fails closed: it is never a silent pass, and never an `Unauthorized` a
client would treat as final.
Postgres remains the source of truth. Control writes the key context into Redis
on mint and drops it on revoke; if that write fails, the mint still succeeds and
the gateway falls back to control (invariant 3 — Postgres never sits on the
streaming path).
## Revocation
Revoking sets `status = "revoked"` and `revoked_at` in Postgres, writes an audit
row, and deletes the Redis cache entry. A cached context that a gateway already
holds ages out at the key TTL, which is why ADR-0004 states revocation
propagates in 60 seconds or less and fails both planes closed.
Revoking an Obol key stops traffic at the Obol hop. On a federated connection it
does not revoke the credential the broker holds — that requires a second,
broker-side disconnect (ADR-0030). See
[/connectors/federated](/connectors/federated).
## MCP OAuth tokens bound to a key
The gateway validates MCP OAuth 2.1 access tokens but never issues them; control
or a fronted authorization server is the issuer
(`apps/gateway/crates/obol-auth/src/jwt.rs`). Two private claims bind a token to
exactly one virtual key:
- `obol_key_hash` — SHA-256 hex of the key's plaintext, which is what the
gateway resolves through the key store.
- `obol_key_id` — must equal the resolved context's `key_id`, or the token is
rejected as invalid.
Validation details worth stating precisely:
- Only asymmetric algorithms are accepted (`RS256`, `RS384`, `RS512`, `ES256`,
`ES384`, `EdDSA`). `none` and every HMAC variant are rejected on the header,
before any network hop — a key-confusion guard.
- `kid` is required; keys come from a JWKS cached for 10 minutes.
- `exp`, `iss`, `aud`, and `sub` are required claims; issuer and audience are
pinned; clock skew tolerance is 30 seconds.
- Error messages never echo token material.
An OAuth caller still resolves to a `KeyContext`, so every downstream check —
scopes, environment, budget, policy — sees one shape.
## Service JWTs are not virtual keys
Internal hops (gateway to control, control to gateway, gateway to a worker) use
short-lived HS256 service JWTs with a pinned issuer and audience
(`obol-gateway`, `obol-control`, `obol-connectors`), signed with
`OBOL_SERVICE_JWT_SECRET`. These authenticate *services*, never an agent and
never a human, and they are never accepted on `/v1/*` or `/mcp`.