Key format
A key is a prefix plus 43 base64url characters encoding 32 random bytes (apps/gateway/crates/obol-auth/src/key.rs):
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 inapps/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’sKeyContext (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
credential_ofsplits theAuthorizationheader.Bearer <token>(scheme case-insensitive, exactly one space) and a bare token are both accepted.- A three-segment value that does not start with
ob_is parsed as a JWT; everything else is parsed as a raw key. - 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). check_usablerejects revoked before expired, and treatsexpires_at <= nowas 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.Revocation
Revoking setsstatus = "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.
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’skey_id, or the token is rejected as invalid.
- Only asymmetric algorithms are accepted (
RS256,RS384,RS512,ES256,ES384,EdDSA).noneand every HMAC variant are rejected on the header, before any network hop — a key-confusion guard. kidis required; keys come from a JWKS cached for 10 minutes.exp,iss,aud, andsubare required claims; issuer and audience are pinned; clock skew tolerance is 30 seconds.- Error messages never echo token material.
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.