--- 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`.