Skip to content

Identity & delegation

The whole attribution chain rests on one distinction: a log that traces back to “marie.dupont” without being able to substantiate it was really her proves nothing.

Proven (OIDC)Declared (--insecure-declared-identity)
principal_issuer in the logthe real issuercli://declared
Expiryfrom the exp claimnone
Verifiedsignature, iss, aud, exp, nbfnothing
Useproductiondevelopment only

Declared mode requires a flag whose name says what it is, and with no identity configuration at all the gateway refuses to start; there is no silent fallback to anonymous mode.

Four design choices worth knowing:

  • No network calls. The JWKS lives inside a signed identity bundle, distributed by the control plane alongside the policy bundle. The gateway stays deployable air-gapped and adds no outbound surface.
  • The identity bundle is signed, like the policy bundle. Whoever can write that file can mint an identity for themselves. That is the same threat as an unsigned rules file, and it deserves the same answer.
  • The algorithm comes from the JWKS, never from the token header. The classic JWT flaw, declaring HS256 and HMAC-signing with the IdP’s public key, is covered by a dedicated test.
  • Expiry is re-evaluated on every act. If the token was renewed on disk, the gateway records a new delegation; otherwise an act under a renewed token would appear authorized by an already-expired delegation.

A token that says “marie.dupont” does not say who was acting in her name. RFC 8693 token exchange does, and Obsign records it:

Token modeActor chainprincipal_kind
User tokenu:marie.duponthuman
Token exchange (act claim)support-copilot → u:marie.dupontdelegated_human
client_credentialsbatch-agentmachine

The third case is the one nobody covers: a batch agent, with no human behind it, deleting in production. It is recognised by the markers the target IdPs actually emit (sub == client_id, Entra ID’s idtyp: "app", Keycloak’s service-account- username prefix), and the policy can then express it:

@id("destructive_requires_human")
forbid (principal, action == Action::"tool_call", resource)
when { resource.destructive && !context.has_human_delegation };

“No agent destroys anything without an identifiable human at the end of the chain.” Cedar also receives context.actor_chain, context.delegation_depth (to bound multi-agent topologies) and context.principal_kind.

Nothing is provider-specific, and that is deliberate. Keycloak, Entra ID, Okta and Ping are OIDC providers: they work as-is. What varies is where each provider files the information, so the identity bundle describes the claim paths, with * for client-dependent segments:

"claims": {
"subject": "/sub",
"scopes": ["/scope", "/scp"],
"groups": ["/groups", "/roles",
"/realm_access/roles",
"/resource_access/*/roles"],
"client_id": ["/client_id", "/azp"]
}

These defaults cover Keycloak and Entra ID. The Keycloak case is worth flagging: roles are never flat, so a naive mapping returns empty groups and no principal in Group::"dba" rule ever matches, with no visible error.

Machine markers follow the same path language and live inside the signed bundle (format obsign-identity/2): widening what counts as human must be signed like any other authorization change.

Two Keycloak realm-side points, done once: an audience mapper (default access tokens carry aud: "account", which the gateway rightly refuses), and token exchange enabled on the agent’s client for the act claim (officially supported since Keycloak 26.2). For SAML, put Keycloak in front as a SAML-to-OIDC bridge, so the gateway never has to know SAML exists.

Providers rotate their signing keys routinely. The gateway makes no network calls, so rotation means reloading the signed identity bundle from disk, where the control plane published the new version.

  • Triggered by an unknown kid — the exact signal of a rotation, so reloading costs nothing in nominal operation. One retry only.
  • The signature is revalidated on every reload — otherwise writing the file would be enough to inject a JWKS.
  • An invalid bundle never takes the gateway down — botched deployment, truncated file, bad signature: the previous configuration stays in force.
  • Bounded frequency — one attempt per second, detected by content hash (mtime granularity can miss close writes).
  • The reload is itself recorded — a config_reload record goes into the audit chain, rejected attempts included: dropping a rogue JWKS on disk is an attack, and the attempt is precisely what an investigation wants to see.