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 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):
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: Every route requires a verified operator session and a membership row for the workspace in the path (ADR-0033); see /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.

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: 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 and /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 <token> (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.

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.