Authentication

An agent authenticates to a Server one of two ways:

When to choose which. Reach for an API key when you are getting started, scripting, or running where no IdP is available. Choose OIDC certs when an identity provider already issues your workloads' identities (Okta, Auth0, Entra ID, Keycloak, a cloud workload-identity system). Then no long-lived secret sits in your agent's environment, and a leaked credential expires on its own in minutes rather than in the 90 days an API key gets by default.

This page covers the API-key path end to end, then the OIDC-cert path: the AGLedger cert exchange and a per-IdP recipe table. Operators have a third door of their own: an IdP bearer sent straight to /v1/admin/*, with no AGLedger credential minted at all. That is the admin SSO path at the end of the page. For endpoint and field-level detail, see the API reference.

API-key path

1. Create the agent

A key is owned by an agent, so create the agent first with POST /v1/admin/agents (admin or platform key). orgId is required; list your orgs to get it:

ORG=$(curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/admin/orgs" | jq -r '.data[0].id')

AGENT=$(curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
  -d "{\"displayName\":\"invoice-bot\",\"orgId\":\"$ORG\"}" \
  "$AGLEDGER_API_URL/v1/admin/agents" | jq -r '.id')

If the agent already exists, list agents with GET /v1/admin/agents and use its id.

2. Mint a key

An operator mints a key with POST /v1/admin/api-keys (admin or platform key). Give it a role (agent, admin, or platform) and a scope profile; do not enumerate scopes by hand. The role fixes the owner: admin with ownerType: "org" and the org id, agent with ownerType: "agent" and the agent id, platform with ownerType: "platform". Any other pair is refused with a 400 that names these three:

curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"role":"agent","ownerType":"agent","ownerId":"<agent-id>","scopeProfile":"agent-full","label":"invoice-bot prod key"}' \
  "$AGLEDGER_API_URL/v1/admin/api-keys"
{
  "keyId": "019fe8a3-b1e0-7686-8565-7c8e89d44e28",
  "apiKey": "agl_agt_QEPWUZFF4bCfcow9FJ12-aSRQrNuN8GNofusmVmNbTY",
  "role": "agent",
  "ownerId": "019fe8a3-b1c3-75e0-80f4-1d1dadf5fde0",
  "label": "invoice-bot prod key",
  "expiresAt": "2027-02-14T09:31:22.417Z",
  "scopes": ["records:read","records:write","completions:read","completions:write", "..."],
  "scopeProfile": "agent-full",
  "allowedIps": null,
  "nextSteps": [
    { "action": "Test the key", "method": "GET", "href": "/v1/auth/me", "description": "Verify the new API key works by calling GET /v1/auth/me with it as a Bearer token" },
    { "action": "Declare capabilities", "method": "PUT", "href": "/v1/admin/agents/019fe8a3-b1c3-75e0-80f4-1d1dadf5fde0/capabilities", "description": "With this admin key, not the new agent key: record which types this agent declares it handles. An inventory the fleet view reads; it refuses nothing. The agent-self route PUT /v1/agents/{agentId}/capabilities needs agents:manage, which no agent key is minted with." },
    { "action": "Create first record", "method": "POST", "href": "/v1/records", "description": "Create a record with self as principal, or have an admin assign work to this agent" },
    { "action": "Revoke this key", "method": "PATCH", "href": "/v1/admin/api-keys/019fe8a3-b1e0-7686-8565-7c8e89d44e28", "description": "Off-board: send { \"isActive\": false } to revoke the key issued by this call. An admin key can revoke any key owned by its own org or by an agent in it; it cannot touch a platform key or another org." },
    { "action": "List this owner's keys", "method": "GET", "href": "/v1/admin/api-keys?ownerId=019fe8a3-b1c3-75e0-80f4-1d1dadf5fde0", "description": "Every key this owner holds, newest first, so you can see what is outstanding before revoking. Paged: replay `nextCursor` while `hasMore` is true." }
  ]
}

The raw apiKey is returned in this response and only this response; it cannot be retrieved later, so capture it now. expiresAt is filled in because the call named none: an admin or agent key gets 90 days by default, which rotation and revocation covers. Read it off the response rather than assuming, and mint the key's replacement before it, since rotation keeps the expiry. The last two nextSteps are the inverse of this call: the key you just handed out is revoked with PATCH /v1/admin/api-keys/{keyId} and { "isActive": false }, and the owner's other keys are one GET away, both reachable by the same admin key that minted it. The ownerId is an agent you created via POST /v1/admin/agents (which takes orgId and displayName); list agents with GET /v1/admin/agents.

Scope profiles (agent-full, admin-standard, admin-observer, …) are listed, with the scopes and roles each carries, at the unauthenticated discovery surface. Read it rather than guessing scope names:

curl -s "$AGLEDGER_API_URL/v1/scope-profiles"

3. Make an authenticated call

Send the key as Authorization: Bearer. Verify it resolves before wiring it into an agent:

curl -s -H "Authorization: Bearer $AGLEDGER_API_KEY" "$AGLEDGER_API_URL/v1/auth/me"
{
  "apiKeyId": "019e61f1-c755-710f-93fb-820b2f4a8446",
  "role": "agent",
  "ownerId": "019e61d4-fbb9-780f-b110-8a64ab46920f",
  "ownerType": "agent",
  "orgId": "019e61d3-c074-73cf-b14b-50b5e94c6845",
  "scopes": ["records:read","records:write", "..."],
  "name": "docs-demo-agent",
  "authType": "api_key",
  "cert": null,
  "oidc": null
}

GET /v1/auth/me is the canonical "who am I" call: it echoes the resolved identity, scopes, and authType. From here the agent uses the same Bearer header on every request.

4. Failure modes

A missing or invalid key returns 401 as an RFC 9457 problem document whose detail says which:

{"type":"/problems/unauthorized","title":"Unauthorized","status":401,"error":"UNAUTHORIZED",
 "detail":"Missing API key. Use Authorization: Bearer <key>","instance":"/v1/auth/me","retryable":false}
{"type":"/problems/unauthorized","title":"Unauthorized","status":401,"error":"UNAUTHORIZED",
 "detail":"Invalid or inactive API key","instance":"/v1/auth/me","retryable":false}

A valid key that lacks the scope for an action returns 403. The detail names the missing scope, and recoveryHint and missingScopes tell an agent how to fix it: mint a key with the right scope profile rather than widening an existing one:

{"type":"/problems/forbidden","title":"Forbidden","status":403,"error":"INSUFFICIENT_SCOPE",
 "detail":"This endpoint requires scope 'admin:system'. Mint a key with this scope, or use a named profile from GET /v1/scope-profiles. Use GET /v1/auth/me to inspect your key's current scopes.",
 "missingScopes":["admin:system"]}

Repeated refusals from one address

Refused credentials are counted per source address, an IPv6 address by its /64 (every address in one subnet is the same caller's to pick). Past RATE_LIMIT_AUTH_FAILURES of them (60 by default) inside one RATE_LIMIT_WINDOW_MS, the address's requests that carry a credential are answered 429 before any lookup until the window ends, with retryAfterSeconds and a recoveryHint that names this limit rather than capacity. Waiting does not fix the credential that tripped it.

What counts as a refusal: an API key that is unknown, revoked, expired or presented from outside its address list, a cert or admin token that does not verify, a wrong METRICS_AUTH_TOKEN, and a federation message naming an unknown hub or carrying a bad signature. A credential that already verified from that same address inside the window (an API key, cert, admin token or the scrape token) is let through, so one client with a stale key does not lock out the working agents behind the same NAT or the same unnamed proxy; one that has not waits out the window. A federation peer gets no such pass, because its hub id is not a secret; a peer that shares its address with other callers belongs in RATE_LIMIT_EXEMPT_IPS.

RATE_LIMIT_EXEMPT_IPS (addresses and CIDR blocks) and RATE_LIMIT_ENABLED=false apply as they do to every limit, and RATE_LIMIT_AUTH_FAILURES=0 turns this one off. The count lives where the route limits do: under RATE_LIMIT_STORE=postgresql every API replica counts against and reads one budget per address (a credential that verified on any replica passes the lockout on all of them), and under the default in-memory store each replica keeps its own. The limit applies to every route, including the ones that need no credential: an address over it that sends any Authorization header gets 429 there too, and one that sends none is unaffected.

A refusal on a route that required the credential is also an auth.failed row in the audit log, written as a sample rather than once per attempt: one row per distinct failure (reason, address, actor, target, and a fingerprint of the credential presented) per window, and at most 100 rows per window per door and refusal reason per replica, the doors being API keys, certs, admin tokens and federation peers. A flood of unknown keys therefore crowds out only more unknown keys, never an expired or revoked key still in use or a bad federation signature. A row the budget suppressed is counted on the next row of its door and reason, as payload.suppressedInWindow.

Three refusals count against the budget and write no row: an API key sent to a route that needs none (/llms.txt, GET /v1/schemas), which is served anonymously; a wrong METRICS_AUTH_TOKEN; and a federation message naming a hub this Server has never peered with. agledger_auth_failures_total{source} counts every refusal, written or not. A refusal of a credential that did verify (a subject outside an issuer's allow-list, a replayed jti, a refused delegation, a federation nonce replay) is written in full, every time.

Restricting a key to an address range

A key can be pinned to the addresses it is allowed to present from. Pass allowedIps when you mint it, or replace the list later on the live key:

curl -s -X PATCH -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"allowedIps":["203.0.113.7","10.0.0.0/8","2001:db8::/32"]}' \
  "$AGLEDGER_API_URL/v1/admin/api-keys/<key-id>"

Entries are addresses or CIDR blocks, IPv4 or IPv6, and a bare address means that address alone, so a fleet on dynamic addressing is named by its range rather than one host at a time. The array replaces the stored list outright; null or [] drops the restriction, and omitting the field leaves the list as it is. An entry that is not an address or a block is refused with 400 naming it, so a typo never reaches the stored list. A replacement is broadcast to every replica as soon as it commits and takes effect on the key's next request there. A replica whose broadcast listener is down instead heals when the listener reconnects or on the auth-cache TTL, about 30 seconds, whichever comes first.

A request from anywhere else is refused with 403:

{"type":"/problems/forbidden","title":"Forbidden","status":403,"error":"IP_NOT_ALLOWED",
 "detail":"Request from IP 192.0.2.7 is not in the key's allowed IP list","retryable":false,
 "recoveryHint":"The key is valid; the source address is not on its allow-list. Retrying from here cannot succeed. ..."}

The address matched is the one the Server sees. Behind a proxy or load balancer that is the proxy unless TRUST_PROXY names it, and an allow-list written from client addresses then refuses everything. The detail above names the address that actually arrived, which is the one to put on the list. Name the proxy by the addresses or CIDR blocks it connects from (TRUST_PROXY=10.0.0.0/8, or a comma list). TRUST_PROXY=true trusts every hop, and the first hop is whatever the client wrote in X-Forwarded-For, so under it any client can present an address on the list; the Server warns at boot when it is set.

Sending a list that does not contain your own address, on the key you are calling with, is refused with 403 SELF_IP_LOCKOUT before anything is written: that key would be refused on its next request and could not PATCH the list back. Pinning some other key to a range you are outside of is allowed, which is the normal case for an operator restricting a fleet. POST /v1/auth/keys/rotate carries the list onto the replacement key, so a rotation does not quietly widen it.

allowedIps governs the API-key path and nothing else. An OIDC-bound cert and an admin SSO bearer carry no allow-list at all (GET /v1/auth/me reports "allowedIps": null for both), because what they present is anchored in your IdP rather than in a stored secret. Restrict those at the IdP or in the network.

Rotation and revocation

API keys expire after 90 days by default. Treat the agl_… value as a secret: store it in your secret manager, never in source, and read expiresAt off the mint response rather than assuming a key is permanent.

Rotate a key with POST /v1/auth/keys/rotate (called with the key being rotated). It issues a new key, returned once (store it securely), and retires the current one. By default the old key is deactivated immediately (it 401s on the next request), which is what you want for a compromised key. For zero-downtime rollover across a fleet, pass { "gracePeriodSeconds": N } to keep the old key valid for a bounded overlap window; the response's previousKeyDeactivatesAt tells you when it stops working, and previousKeyDeactivated is true only on the immediate cutover, the one case where previousKeyDeactivatesAt is null. Deploy the new key everywhere, then let the old one lapse. The grace window is capped by AUTH_KEY_ROTATION_MAX_GRACE_SECONDS (default 604800 = 7 days; 0 disables grace), and a window past the cap is refused with a 400 naming the maximum rather than quietly clamped. A key is rotated once: rotating a key that is still inside the grace window of an earlier rotation is refused with 409 KEY_ALREADY_ROTATED, and existingId names the replacement that rotation minted. The replacement carries the old key's label, scopes, scope profile and IP allow-list. As an alternative to rotation, mint a second key via POST /v1/admin/api-keys and revoke the old one once traffic has moved (the multi-active-keys model).

Rotation does not renew. It replaces the secret and keeps the expiry: the replacement's expiresAt is the instant the rotated key had, or earlier where the role's lifetime cap ends first, and a key with an expiry never rotates into a later one or into one without. A key that had no expiry gets the role's default lifetime. Rotating is self-service, so if it restarted the lifetime a stolen key could rotate itself just before each expiry and never lapse. To keep a credential past its expiry, mint the replacement with POST /v1/admin/api-keys before that instant: an admin key holding admin:keys mints for admin and agent owners, and a platform key mints platform keys, including the successor to an expiring platform key. An agent key cannot mint, so its renewal is the operator's job. The rotate response's nextSteps state the kept expiry and name this route.

Every rotation lands on the chain as an AUTH_KEY_ROTATED entry signed under the key that rotated (the evidence packet renders it as "API key rotated by its holder", distinct from the admin key actions), and on the SIEM stream as auth.key_rotated.

Rotation applies to API keys only. An ephemeral cert has no api_keys row of its own, so POST /v1/auth/keys/rotate presented with one is refused 422 NOT_A_ROTATABLE_CREDENTIAL with a recoveryHint pointing at POST /v1/auth/oidc/cert: certs are short-lived by design and are replaced, not rotated. An admin SSO bearer never reaches this route at all, since SSO bearers are read only under /v1/admin, so it gets a 401. An SSO operator who needs a long-lived credential mints one with POST /v1/admin/api-keys and rotates that.

Revoke a compromised key by toggling it inactive with PATCH /v1/admin/api-keys/{keyId} (body { "isActive": false }), or revoke many at once with POST /v1/admin/api-keys/bulk-revoke. The PATCH answers with the key's keyId, the field the mint and the listing name it by, beside its isActive, scopes, scopeProfile and allowedIps as they stand after the call. Revocation is broadcast to every replica as soon as it commits and is immediate there. A replica whose broadcast listener is down instead heals when the listener reconnects or on the auth-cache TTL, about 30 seconds, whichever comes first.

Every revocation leaves its lineage on the row. GET /v1/admin/api-keys returns revokedAt, revokedByKeyId and revocationReason on a key that has been turned off: the reason you sent, or the path that did it when you sent none (admin_toggle, bulk_revoke, account_deactivated, provisioning_prune, rotated). A key minted by POST /v1/auth/keys/rotate carries rotatedFromKeyId, the key it replaced, so a rotation chain reads backwards from any link. On a rotation with a grace window the old key's revokedAt is the rotate instant and deactivatesAt is when it stops working. Restoring a key with a platform credential clears the three revocation fields. isActive stays the flag authentication reads.

The default lifetime. API_KEY_DEFAULT_LIFETIME_SECONDS is what a key gets when the mint names no expiresAt. It ships at 7776000 (90 days) and applies to admin and agent keys by every door: the admin API, POST /v1/auth/keys/rotate, the provisioning YAML (which re-mints the window on each reconcile) and the generate-api-key script. Set it to 0 for keys that never expire.

Platform keys are exempt, under AGLEDGER_PLATFORM_KEY_DEFAULT_LIFETIME_SECONDS, which ships at 0. Minting a platform key requires a platform credential, so an install whose only platform key expired, with no trusted issuer mapped to the platform role, cannot mint its way back. Set a platform lifetime only once that recovery path exists. The engine warns at boot when the last active platform key is inside 14 days of its expiry.

API_KEY_DEFAULT_LIFETIME_SECONDS=7776000               # 90 days, the shipped value
AGLEDGER_PLATFORM_KEY_DEFAULT_LIFETIME_SECONDS=0       # platform keys are exempt

Cap the lifetime. API_KEY_MAX_LIFETIME_SECONDS is a different control and is unset by default. The default above fills in what a caller did not ask for; the cap is a ceiling nobody can exceed. An expiresAt past it is refused with 422 naming the latest date the Server will mint, rather than clamped, so nobody builds a rotation schedule around a date the Server never agreed to. It applies to every role, platform included, and where both are set a mint that names no expiry gets the smaller of the two.

A malformed value on any of the three is refused at boot rather than defaulted, because 0 means "no bound": a typo would turn the control off silently.

API_KEY_MAX_LIFETIME_SECONDS=7776000   # 90 days

All three bound only the expiry a mint or rotation sets, not a key that already has one, with one exception: a provisioning reconcile monotonically extends, never shortens, the expiresAt of a declared key each time it reconciles inside the current window. A key nothing mints, rotates or re-declares keeps exactly the expiry it has, which on an install that predates the default is none, so upgrading starts with an inventory (the engine logs the count at boot as well):

# keys nothing will ever retire on your behalf
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/admin/api-keys?neverExpires=true&isActive=true"

# the renewal queue: what stops authenticating in the next week
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/admin/api-keys?expiresBefore=$(date -u -d '+7 days' +%Y-%m-%dT%H:%M:%SZ)&isActive=true"

neverExpires and expiresBefore are cross-owner filters, so they cannot be combined with ?ownerId=; the request is refused rather than silently ignoring them. orgId, ownerType, role, isActive, createdBefore, lastUsedBefore and neverUsed are cross-owner too, and combine freely except for the one contradictory pair, neverUsed=true with lastUsedBefore, which is refused for the same reason. To narrow to one owner, pass ?ownerId= alone and filter the page yourself.

The same numbers are published as metrics: agledger_api_keys_expiring_soon with window="24h", "7d" and "never". The shipped alert fires on the 24-hour window, not the 7-day one, because under a lifetime cap a non-zero 7-day count is the steady state rather than an event, and an alert that never clears is not an alert. An expired key stops authenticating with no warning to its holder, and the failure surfaces as the agent it belonged to going quiet, so watch the gauge rather than the calendar. Retiring the holder rather than the key is offboarding.

The other inventory: keys nobody is using

An expiring key is work with a deadline. A dormant key is a credential that still authenticates and that nothing has presented, which is the one an offboarding leaves behind. Two filters answer it:

# presented once, and not in the last 90 days
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/admin/api-keys?isActive=true&lastUsedBefore=$(date -u -d '-90 days' +%Y-%m-%dT%H:%M:%SZ)"

# minted long enough ago to have been used, and never was
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
  "$AGLEDGER_API_URL/v1/admin/api-keys?isActive=true&neverUsed=true&createdBefore=$(date -u -d '-90 days' +%Y-%m-%dT%H:%M:%SZ)"

The two are disjoint by construction: a key that has never authenticated was not last used before anything, so lastUsedBefore never returns one and sending both is refused rather than answering an empty page. neverUsed without createdBefore is legal on the listing and refused on POST /v1/admin/api-keys/bulk-revoke, because a sweep of every key that has not yet been used takes the replacement you minted a minute ago with it.

lastUsedAt is written on a key's next authenticated request, once per key per 5 minutes per replica. isActive and expiresAt move under a walk as well, but only when an admin or the provisioning reconciler writes them; this is the one predicate ordinary agent traffic moves, so a dormant key presented during your walk drops out of a later page. Read a page as a snapshot rather than a set you can re-derive. Watch agledger_api_key_last_used_update_failures_total beside it: while that write is failing, a key in daily use reads as dormant. The same counts are published as agledger_api_keys_dormant with window="30d" and "90d", which fold the never-used keys in. No alert ships on it: a credential going quiet is an inventory question, not an incident, and an install with seasonal agents would page on its own steady state.

Instead of carrying a long-lived secret, an agent presents a token from your own identity provider and exchanges it for a certificate valid for minutes. Register the issuer once and tell the Server which agent a token speaks for, then per agent exchange a token for a cert and use the cert.

The SDK (oidcCertCredential), the CLI and the MCP server (AGLEDGER_OIDC_TOKEN_CMD or AGLEDGER_OIDC_TOKEN_FILE) make the exchange and the re-exchange for you; the calls below are what they send. For the whole path run end to end on Keycloak, see the insurance recipe's OIDC variant.

1. Register the issuer (once, per IdP)

An operator registers your IdP as a trust anchor with POST /v1/admin/trusted-issuers (platform key). claimMapping tells the Server which token claims carry the agent identity and scopes:

curl -s -X POST -H "Authorization: Bearer $AGLEDGER_PLATFORM_KEY" -H "Content-Type: application/json" \
  -d '{
    "orgId": "<org-id>",
    "issuerUrl": "https://your-tenant.idp.example/",
    "expectedAudience": "agledger",
    "appliesTo": "agent",
    "claimMapping": { "scopes": "agledger_scopes", "agent_id": "agledger_agent_id" },
    "maxCredentialTtlSeconds": 600
  }' \
  "$AGLEDGER_API_URL/v1/admin/trusted-issuers"
{
  "id": "019e61fe-dddd-7123-a36d-f292a5952984",
  "issuerUrl": "https://your-tenant.idp.example/",
  "jwksUri": "https://your-tenant.idp.example/.well-known/jwks.json",
  "expectedAudience": "agledger",
  "appliesTo": "agent",
  "claimMapping": { "scopes": "agledger_scopes", "agent_id": "agledger_agent_id" },
  "maxCredentialTtlSeconds": 600
}

Omit jwksUri to let the Server discover it from ${issuerUrl}/.well-known/openid-configuration; supply it explicitly if your IdP does not publish discovery. issuerUrl must exactly match the iss claim in the tokens the IdP issues, and expectedAudience must match their aud.

Make the IdP issue single-audience tokens. When a token's aud holds more than one entry, the row has to name the expected azp in expectedAzp, and that names one client. There is one row per issuer, audience, appliesTo and org, so an IdP minting multi-audience tokens gets one workload per org on this door: a second client's row is refused 409 with reason: "TRUSTED_ISSUER_EXISTS" and existingId naming the row that holds the key, and its token presented against the first row is refused 401 with reason: "wrong_azp". Keycloak adds account to aud by default. Set fullScopeAllowed: false on each client and its tokens carry only your audience, so one row with no expectedAzp serves the whole fleet. Read the aud of a token your IdP actually mints before registering the row.

By default the row admits every subject the IdP will issue a token for, and the IdP's own app assignment is the gate. To add a second gate on this Server, list the subjects the row may admit with "subjectAllowlist": ["workload|deploy-bot", "workload|audit-bot"], on the create body, a PATCH, or the provisioning YAML. A token that verifies but whose sub is not listed is refused with 401, reason: "subject_not_allowlisted" and a recoveryHint naming the row, and the refusal is final: no other row for the same issuer and audience admits that token. The list bounds every path a row serves, so on this appliesTo: "agent" row that is the cert exchange alone, while on an admin or principal row it bounds admin bearers and delegated on-behalf-of tokens the same way. null lifts the list; an empty list is refused, since a row that admits nobody is spelled enabled: false.

Which sub gets compared depends on the door: on an agent row it is the exchanging workload's subject, on an admin row the bearer's own subject, and on a principal row the delegation token's subject, meaning the party the work is done for rather than the actor in act.sub. A row registered appliesTo: "any" serves all three and one list then gates all three, so split the row per appliesTo before adding a list that only one door should carry.

Narrowing the list is not revocation. Dropping a subject, or setting enabled: false, refuses the next exchange; certs already minted keep authenticating until their own expiresAt, which is at most the row's maxCredentialTtlSeconds (600 seconds by default, 3600 at the ceiling). To end them immediately, call POST /v1/admin/trusted-issuers/{id}/revoke-certs, which revokes every live cert minted from that one issuer. Use both when an IdP is compromised, and disable first: once enabled: false commits, no mint can land, one already in flight included, so the revoke-certs that follows leaves nothing live. Revoking first leaves every cert minted before the disable.

Binding the token to an agent

The exchange refuses a token it cannot tie to an agent, because a cert with no agent binding would authenticate as platform-tier across orgs. Three ways to tie it, and any one is enough:

  1. On the agent, from the token's own identity. Give the agent the issuer and subject it answers to, and the IdP needs to know nothing about AGLedger:

    curl -s -X PATCH -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
      -d '{"oidcIss":"https://your-tenant.idp.example/","oidcSub":"system:serviceaccount:prod:worker"}' \
      "$AGLEDGER_API_URL/v1/agents/<agent-id>"
    

    This is the path for Azure managed identities, Kubernetes service-account tokens and SPIFFE workloads, whose sub is not a value you get to choose. Read the sub off a token the IdP has just minted rather than from configuration: a Keycloak service account's sub is the id of its service-account user, and it changes if the realm is re-imported. Both fields together or neither: a subject is unique only within its issuer. The pair is unique per org and issuer, and it is settable on POST /v1/admin/agents and in provisioning YAML (oidcIss / oidcSub on the agent) as well, which is where a fleet declares it.

  2. From a claim, with claimMapping.agent_id as above. This needs your IdP to carry AGLedger agent UUIDs, which is fine when you control the token's claims and impossible when you do not. A claim naming an agent that carries oidcIss/oidcSub for a different subject is refused with 403 CERT_AGENT_BINDING_MISMATCH: a bound agent exchanges only for its own subject.

  3. By auto-provisioning, which creates the agent instead. A row registered with "autoProvisionAgents": true and an autoProvisionScopeProfile (agent-full, agent-readonly or agent-performer-only) turns the first exchange from a subject this Server has never seen into a new ephemeral agent in the row's org, capped at autoProvisionMaxAgents (1000 by default). The row needs an orgId.

The mapped claim is read first, then the binding on the agent row, and auto-provisioning runs only when neither resolves. If none applies, the 400 names all three and the issuer the binding would have to carry.

The token decides the agent, never the request. agentId in the exchange body is an assertion checked against the agent the token binds to: the same id is accepted, and a different one, or any agentId on a token that binds to no agent, is refused with 403 CERT_AGENT_BINDING_MISMATCH and written to the audit log as auth.failed with reason cert_agent_binding_mismatch. The route needs no credential and agent ids are listed at GET /v1/agents, so a body that could choose the agent would let any token from the issuer act as any agent in its org. An agent federated in from a peer Server (listed at GET /v1/peer-agents) is never bound: 403 SHADOW_AGENT_CERT_FORBIDDEN.

A row's autoProvisionScopeProfile is also a ceiling on every cert the row mints: the token's mapped scopes can narrow it, never widen it, and a token that shares no scope with it is refused 422. A scopes claim that names only admin-only scopes (admin:keys, agents:manage and the rest) is refused with 422 and currentState: scope_claim_admin_only, the same way a claim naming only unknown scopes is refused as scope_claim_unknown. No agent cert carries an admin scope, and dropping them would leave an empty claim, which on a row with a scope profile means the whole profile.

A row with no autoProvisionScopeProfile has nothing to grant a token whose mapped scopes claim is absent or empty, so that exchange is refused with 422 and currentState: scope_claim_empty rather than minting a cert with no scopes. Any one of three fixes works: set autoProvisionScopeProfile on the row, point claimMapping.scopes at the claim your IdP already puts scopes in, or have the IdP send scopes in the claim the row maps. A provisioning-managed row takes the first two in its YAML entry and a reload, since PATCH answers it 409.

2. Exchange a token for a cert

The agent obtains an OIDC token from your IdP (see the per-IdP recipes below), generates an Ed25519 keypair, and proves possession of the private key. The proof is Ed25519("agledger.oidc.cert.v1\n" + sub), base64. It then calls POST /v1/auth/oidc/cert:

curl -s -X POST -H "Content-Type: application/json" \
  -d '{ "oidcToken": "<jwt-from-idp>", "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "<base64url>" }, "proofOfPossession": "<base64-sig>" }' \
  "$AGLEDGER_API_URL/v1/auth/oidc/cert"
{
  "cert": {
    "id": "b6049896-63d8-4e8e-82c9-6cd8689689a5",
    "orgId": "019e61d3-c074-73cf-b14b-50b5e94c6845",
    "agentId": "019e61d4-fbb9-780f-b110-8a64ab46920f",
    "oidcIss": "https://your-tenant.idp.example/",
    "oidcSub": "workload|docs-demo",
    "publicKeyThumbprint": "sha256:369cb75b1875df7091e609321d411d886e91c0c4ec9c363edba5fb44ff2e9796",
    "scopes": ["records:write", "records:read"],
    "issuedAt": "2026-05-26T01:55:53.000Z",
    "expiresAt": "2026-05-26T02:05:53.000Z"
  },
  "certJws": "eyJhbGciOiJFZERTQSIsImtpZCI6IjZhNjM5MjQ4…"
}

The cert is bound to the agent's key (publicKeyThumbprint) and expires in minutes: at the row's maxCredentialTtlSeconds (60 to 3600, 600 by default), or at the token's own exp if that comes first. A lifetime that would come out under 60 seconds is refused 422 OIDC_TOKEN_NEAR_EXPIRY; fetch a fresh token and retry. The agent re-exchanges before expiry; a leaked cert is dead almost immediately. Its scopes are what the token's claimMapping.scopes claim names, held to the row's autoProvisionScopeProfile when one is set (the whole profile when the claim is empty). So when a cert is refused 403, read the scope claim in the token first, then the row's profile.

A cert the Server recognizes and refuses answers 401 with a reason, so a client can tell it from a missing bearer. cert_revoked and cert_expired are cured by exchanging a fresh token, unless the issuer, subject or agent has since been turned off, in which case that exchange names the refusal. cert_scopes_empty is a cert that carries no scopes (earlier releases minted these), and exchanging again either returns a cert with scopes or the 422 above.

One exchange per token id. A unique index over (trusted_issuer_id, token id) refuses a second exchange of the same token, so a captured JWT buys nothing once it has been spent. The id is the token's jti claim, or the claim the matched row names under the claimMapping logical name jti when the token carries no jti, with the standard jti winning when both are present. An Entra ID row, where the IdP mints uti and never jti, gets this protection by adding claimMapping: { "jti": "uti" }. This door enforces it unconditionally: the jtiSingleUse flag under the admin SSO path below governs the admin bearer only and does not need to be set here.

A repeated id is a 409 with reason: "OIDC_JTI_REPLAY". A token that resolves no id at all, which is Auth0's default access-token profile and a Google service-account ID token, is exchangeable repeatedly, and the defense there is the cert TTL and the token's own exp. A token whose resolved id is an empty string, or longer than 512 characters, is refused with 400 naming the defect rather than stored: an absent id is excluded from the unique index while an empty one is not, so storing one would take the index entry for the whole row and turn the next exchange, from any subject, into a false replay.

When the exchange itself is refused 401, the body carries a reason if a trusted-issuer row rejected the token, from the same vocabulary as the admin SSO table below, and that separates a token problem from a Server-side configuration fault. jwks_fetch_failed means the Server could not fetch or parse the IdP's JWKS: the token may be fine, so check that the row's jwksUri is reachable from the Server and returns a valid JWKS document. A token that is not a parseable JWT at all, or carries no iss or aud, is refused before any row is consulted, with a detail and recoveryHint and no reason.

3. Use the cert

Send certJws as the Bearer token. GET /v1/auth/me now reports cert-backed identity; note authType and the populated cert / oidc blocks (which were null on the API-key path):

{
  "role": "agent",
  "ownerId": "019e61d4-fbb9-780f-b110-8a64ab46920f",
  "scopes": ["records:write", "records:read"],
  "authType": "ephemeral_cert",
  "cert": { "id": "b6049896-…", "thumbprint": "sha256:369cb75b…", "expiresAt": "2026-05-26T02:05:53.000Z" },
  "oidc": { "iss": "https://your-tenant.idp.example/", "sub": "workload|docs-demo" }
}

A cert-authenticated POST /v1/records notarizes exactly as an API key would, and the chain entry carries the OIDC identity that authorized it.

Optional per-request attestation

On top of cert auth, an agent can sign each request body with its bound key. Hash the exact raw body bytes with SHA-256 and send the lowercase hex digest as X-Agent-Signature-Content-Hash: sha256:<hex64>. Sign the UTF-8 bytes of agledger.agent.sig.v1\n<hex64> (the literal prefix, one newline, then the same hex digest) with Ed25519 under the cert-bound key, and send the 64-byte signature in standard base64 as X-Agent-Signature. A hash that does not match the body is a 400 and a signature that does not verify is a 401. The chain entry then carries the agent's own client-side attestation of that specific request, not just the cert.

The headers are read on every authenticated route, not a fixed list (the public discovery surfaces excepted). Under cert auth they are verified wherever they arrive, and a verified signature is sealed on every chain entry the request writes, so signing every body is safe. POST /v1/scitt/entries verifies them over the COSE body but writes no chain entry, so nothing is sealed there. One signature covers the whole body: each record a bulk create makes carries the signature over the whole batch, and on /a2a it covers the whole JSON-RPC envelope. A request with no body that carries the headers is refused with a 400.

The headers are verified only under cert auth. A request authenticated any other way that carries any X-Agent-Signature header (an API key on any route, or the admin or platform OIDC bearer on the admin routes that accept it) is refused with a 400 VALIDATION_ERROR naming the headers it sent, and /a2a answers the same refusal as JSON-RPC -32600. What the Server refuses before the route runs is answered before it: a refused credential, a rate-limit refusal on arrival, a refused AGLedger-On-Behalf-Of token, a request body the Server cannot read or refuses (on /a2a, -32700 and -32600), a deactivated account and a missing route scope. The check runs once the body is read, because under cert auth it hashes those bytes. A record- or role-level refusal from the route itself is answered after, as are an idempotency replay or conflict and, on /a2a, a batch's operation top-up and each action's shared bucket, so each of those can still meet the resend. Drop the headers, or authenticate with the agent's cert and sign with its bound key.

The SDK's oidcCertCredential, the CLI and the MCP server all sign every request this way when they authenticate with a cert. To re-check those signatures offline you need each cert's public key, and an export does not carry it: it holds the signature and the cert thumbprint. Keep the publicKeyJwk each agent sent at exchange (the SDK credential exposes it) and pass them to the verifier, as the SDK guide shows. The CLI and the MCP server hold their key in memory and discard it, so signatures they sealed can be re-checked only from the cert's issuance entry in a full vault dump, not from a per-record export.

Acting on behalf of another identity

When one agent acts for another principal, the delegation is sealed into the signed record so an auditor can tell a claimed delegation from a verified one. The on_behalf_of block inside the Signed Statement carries an explicit provenance marker, not just a presence/absence signal:

The provenance: "asserted" marker is stamped by the engine itself, after the caller's input, so a caller cannot forge validated: true to dress an unverified assertion up as a verified delegation. An auditor reads the positive provenance field, present on every delegated record, rather than inferring trust from the mere absence of validated: true. The marker is sealed inside the signed COSE envelope, so it is itself tamper-evident.

Presenting a validated delegation. Send the RFC 8693 result token your IdP issued, a compact JWS, in the AGLedger-On-Behalf-Of header alongside the agent's own credential:

curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" \
  -H "AGLedger-On-Behalf-Of: $DELEGATION_TOKEN" -H 'Content-Type: application/json' \
  -d @record.json "$AGLEDGER_API_URL/v1/records"

The engine validates the token (signature, iss, aud, exp) against a trusted_issuers row whose appliesTo is principal or any, then seals predicate.on_behalf_of into the signed chain entry with validated: true, the RFC 8693 act chain, and a binding verdict. The token has to carry an act claim: a plain impersonation token is refused with 400, because what the chain records is who acted, not only who they acted for.

The binding verdict is bound when the caller has an engine-validated identity of its own, which means the cert path above. The top-most act entry must then be that identity on both halves (act.sub equal to your validated subject, and the actor issuer, act.iss or the token's own iss when act.iss is omitted, equal to your validated issuer), and a mismatch on either is refused with 403 ACTOR_BINDING_MISMATCH. Comparing the issuer as well as the subject is what stops a bound verdict from conflating two identities that merely share a sub string across different IdPs. On API-key auth there is no cryptographic caller identity to compare against, so the delegation is recorded unbound. Either way the agent's own credential is recorded beside the subject it acted for, not in place of it.

The header is read on five routes: POST /v1/records, POST /v1/records/{id}/transition, POST /v1/records/{recordId}/completions, POST /v1/records/{id}/verdict and POST /a2a. Other chain-writing routes ignore it. Those five, and only those five, declare AGLedger-On-Behalf-Of as an optional header parameter in the OpenAPI spec, so a generated client carries it without reading this page.

Delegation needs its own trust anchor: register the issuer with appliesTo: "principal" (or "any"), a second trusted_issuers row beside the "agent" one the cert exchange uses. Without a matching row a presented token is refused with 401 rather than ignored, and the body names the registration it wanted. If that principal row carries a subjectAllowlist, the value checked against it is the delegation token's own sub, the principal the work is done for, never the actor in act.sub. A subject that is not listed is refused with 401 and reason: "subject_not_allowlisted", and that answer is final, so a second row for the same issuer and audience with no list does not admit it. Delegation tokens are never deduplicated on any token id, so jtiSingleUse on the row has no effect here.

On a record create, metadata.actContext is the unvalidated alternative for a caller whose IdP cannot mint a delegation token, and sending both on one create is refused with 400. That covers POST /v1/records and the A2A create_record action, which takes the same body fields. The three routes that are not a create have no actContext path, so there the header is the only way to record a delegation.

Per-IdP recipes

Every IdP uses the same two AGLedger calls above. What differs per IdP is the issuer URL, the audience to request, and how the workload obtains a token. Configure your IdP to issue the agent identity and scopes under the claim names you set in claimMapping (above: agledger_agent_id, agledger_scopes).

IdPissuerUrl shapeHow the workload gets a token
Oktahttps://<tenant>.okta.com/oauth2/<authServerId>Client-credentials grant against the authorization server's /v1/token, with aud set to your expectedAudience
Auth0https://<tenant>.auth0.com/Client-credentials grant with the audience parameter set to your expectedAudience
Entra IDhttps://login.microsoftonline.com/<tenantId>/v2.0Client-credentials (or managed identity) token for the app registration whose URI is your expectedAudience
Keycloakhttps://<host>/realms/<realm>Service-account client-credentials grant against the realm token endpoint; set fullScopeAllowed: false on each client so the token is single-audience
GCP Workload Identityhttps://accounts.google.com (or your WIF pool issuer)Fetch an instance/workload identity token from the metadata server with audience set to your expectedAudience
Kubernetes service accountthe cluster's projected-token issuer (kubectl get --raw /.well-known/openid-configuration)Mount a projected service-account token with audience: <expectedAudience>; the pod reads it from the token file

For each: set issuerUrl/expectedAudience on the trusted issuer to match what the IdP issues, register it (step 1), then have the workload obtain a token its standard way and exchange it (steps 2–3). The exchange and cert usage are identical across IdPs. The per-IdP shapes above follow each provider's standard setup; confirm them against your live IdP, since only the exchange side is ours.

On a FIPS host, API keys are the only path

The cert exchange and the per-request attestation both bind Ed25519 agent keys, and a host running OpenSSL in FIPS mode cannot compute Ed25519. On such a host the Server refuses those surfaces explicitly rather than failing them closed as bad signatures:

curl -s -X POST -H "Content-Type: application/json" -d @cert-request.json \
  "$AGLEDGER_API_URL/v1/auth/oidc/cert"
{"type":"/problems/validation-error","title":"Validation Error","status":400,
 "detail":"Ephemeral cert issuance is unavailable on this host: it verifies Ed25519 agent keys, which this host's crypto provider cannot compute (FIPS mode).",
 "recoveryHint":"Authenticate with an API key instead (POST /v1/admin/api-keys mints one; agent scope profiles are listed at GET /v1/scope-profiles). Ed25519-bound agent features require a non-FIPS host today."}

X-Agent-Signature is cert-bound, so it is unreachable on the same host by construction: no cert can be issued to bind it to. The one way to present one anyway is a cert row that predates the host going FIPS, from a database restored off a non-FIPS install, and that verification refuses with the same reason rather than reporting a signature failure. The distinction matters during an incident, since a forgery message and a host-capability message call for different responses.

Nothing on the API-key path is affected. Minting, GET /v1/auth/me, scopes, rotation, and revocation behave as documented above, and records notarize normally, because the Server signs the chain with ES256 on such a host rather than Ed25519. Plan for API keys as the agent credential when you deploy under FIPS; FIPS 140 hosts covers the rest of that configuration.

Admin SSO path (operators on /v1/admin/*)

The two paths above give an agent a credential. This one gives an operator one, and it mints nothing: your IdP issues a JWT and the operator sends it straight to /v1/admin/* as a Bearer token. There is no exchange step and no AGLedger artifact to store or rotate. Reach for it when your people already sign in to Okta, Auth0, Entra ID or Keycloak and you would rather revoke an account there than chase an API key here.

1. Register the admin anchor

Same registry, a different appliesTo. The one extra requirement is claimMapping.role, which names the IdP claim carrying the AGLedger role:

curl -s -X POST -H "Authorization: Bearer $AGLEDGER_PLATFORM_KEY" -H "Content-Type: application/json" \
  -d '{
    "issuerUrl": "https://your-tenant.idp.example/",
    "expectedAudience": "agledger",
    "appliesTo": "admin",
    "orgId": "<org-id>",
    "claimMapping": { "role": "agledger_role", "scopes": "agledger_scopes" },
    "label": "Corporate SSO (operators)"
  }' \
  "$AGLEDGER_API_URL/v1/admin/trusted-issuers"

Which role to map. The mapped value has to resolve to platform or admin; anything else is not a role this Server knows. admin is org governance, bound to exactly one Org, the same reach an admin API key has. platform is the cross-org super-admin, holds every scope, and is the only role that reaches platform-only operations such as vault key rotation and license reload. Map platform for the SRE operators who need it, not for everyone who can sign in.

Which Org an admin bearer binds. The matched row decides, never the token: nothing in an IdP JWT is authoritative about AGLedger tenancy. A row with an explicit orgId binds that Org, which is the recommended registration. A row with no orgId is global, and on a Server running its single Org that is unambiguous. A platform bearer is served only by a global row, so an install that wants both registers two rows: one per-Org row for admin, one global row for platform. Two rows under different orgIds for the same issuer and audience is the case nothing can resolve, and a valid token then gets a 401 whose reason is ambiguous_org_binding.

Which scopes the bearer holds. With no scopes entry in claimMapping, an admin bearer gets the admin-standard profile, exactly what POST /v1/admin/api-keys gives an admin key minted with no explicit scopes. Add the mapping and the named claim projects them instead: send an array of strings or a space-separated string, filtered against the vocabulary at GET /v1/scope-profiles. That is how least-privilege SSO works, one IdP group to one scope set.

Once a scopes mapping exists on the row, the default is off the table for every token that row validates. A token whose scope claim is missing, empty, the wrong type, or made up entirely of values outside the vocabulary gets the empty set, not the profile. This bites on Okta and Entra ID, which omit an empty claim rather than sending []: a user in the admin role group and in no scope group presents a token with no scope claim at all and gets an identity that authorizes nothing. Every /v1/admin/* action requires a scope, so the symptom is a 403 on everything, naming the scope that is missing, and the fix is at the IdP.

Multi-audience tokens need expectedAzp. Keycloak adds account to aud by default and an Auth0 access token minted for a custom API carries the userinfo audience beside it; an Entra ID access token is single-audience by default, and other IdPs vary. Read the aud of a token your IdP actually mints before deciding, because on a single-audience token expectedAzp is never consulted. Whenever aud is an array with more than one entry, OIDC Core section 3.1.3.7 requires an azp claim and the row must declare which value to expect: set expectedAzp to that azp, typically the client_id. A multi-audience bearer presented against a row with no expectedAzp is refused as misconfigured_issuer on this door too: once no sibling row has served the bearer, the 401 carries that reason and a detail naming the row and the field it is missing. Decode a real token and look at aud before concluding the row is fine.

Single use per token id, when the client can do it. By default a bearer is accepted on every request until its exp, which is what a polling dashboard or a grant metered per month needs. Register the row with "jtiSingleUse": true (on the create body, a PATCH, or the provisioning YAML) when the client already mints a token per request, such as a per-call client_credentials grant against Okta or Keycloak. The Server then accepts each token id once: the accepting request writes the (issuer, id) pair to a register every replica reads, and a second presentation anywhere is refused with 401 and reason: "jti_replayed".

The id the register holds is the token's jti claim, or the claim the row names under the claimMapping logical name jti when the IdP does not spell it jti. The standard jti wins when a token carries both.

IdPToken idWhat the row needs
Oktajti, by defaultnothing
Keycloakjti, by defaultnothing
Entra IDuti, never jti, on both access and ID tokensclaimMapping: { "jti": "uti" }
Auth0none on the default access-token profileswitch that API to the RFC 9068 profile, which mints a jti, or leave the flag off
Google service accountnone, under any namecannot be made single use

Entra ID has no setting that adds a jti. Its token reference documents uti as the equivalent of jti in the JWT specification, and it is unique per token, so mapping it is the whole fix.

A projected Kubernetes service-account token is not a per-request credential. It does carry a jti, but the kubelet rewrites the token file only at 80 percent of its TTL, about 48 minutes on the one-hour default, and the pod re-presents whatever is in the file. One id would be presented for the whole window and every request after the first would be refused. Register that workload on a row with the flag off.

Three more details decide whether you can turn it on:

The register is swept every 5 minutes: rows whose token is past its exp plus the clock tolerance are deleted. A run drains in batches for up to 30 seconds and carries any backlog over to the following ticks, so what one run clears is what your database can delete in that time rather than a fixed number of rows. A run that spends its budget with rows still waiting says so, on agledger_maintenance_sweep_behind_total{task="oidc-jti-cleanup"} and in a WARN naming the table. Nothing is refused wrongly when that happens: the rows left behind belong to tokens that can no longer be presented, so the cost is table size until the rate that feeds it drops. That counter is on the worker process, unlike the others on this page, so scrape both.

The switch is per row and off by default because whether a client can mint per request is a property of that IdP and that client, not of this Server. It governs the admin bearer alone, but the token id it reads is shared with the cert exchange, which is single use per token id whatever this flag says and resolves the id the same way. So the claimMapping jti entry an Entra row needs here also makes that door single use per uti.

Which subjects the row admits. subjectAllowlist works here as it does on the cert path above, and on this door the value compared is the admin bearer's own sub. The list is checked once the row has been chosen to serve the token's role, so a per-Org admin row's list does not stop a platform bearer reaching the global row beside it, and the refusal is final: no sibling row without a list admits the subject this one refused.

2. Send the bearer

curl -s -H "Authorization: Bearer $IDP_JWT" "$AGLEDGER_API_URL/v1/admin/agents?orgId=<org-id>"

The JWT authenticates /v1/admin/* and GET /v1/auth/me. Every other /v1/ surface still takes an API key or an ephemeral cert. GET /v1/auth/me is the call to make when a 403 leaves you unsure what the IdP's claims actually granted:

{
  "role": "admin",
  "orgId": "019e61d4-fbb9-780f-b110-8a64ab46920f",
  "scopes": ["records:read", "audit:read"],
  "authType": "oidc",
  "cert": null,
  "oidc": { "iss": "https://your-tenant.idp.example/", "sub": "michael@corp.example.com" }
}

Attribution is per person, not per credential: chain and audit rows carry the IdP iss and sub alongside the Org, so "who did this" survives even though no API key was involved.

3. Failure modes

This door answers in two shapes, and telling them apart is the whole diagnosis.

The bare 401. Most refusals hand the request back to API-key auth, which answers "Missing API key" with no reason and no recoveryHint. That is deliberate: a misconfigured trust anchor must never be able to block API-key auth, which is the credential you recover the install with. Everything in this list produces that bare 401:

CauseWhat to check
No candidate rowThe token's iss, or every value in its aud, matches no enabled row
wrong_issueriss is compared as an exact string, trailing slash included
wrong_audienceNo registered row expects any audience the token carries
wrong_azpThe token carries several audiences and its azp is missing or is not the row's expectedAzp
expired / not_yet_validexp passed, or nbf/iat in the future, which on a fresh token means clock skew between the IdP and the Server
invalid_signature, jwks_no_matching_keyUsually an IdP key rotation the JWKS cache has not picked up
jwks_fetch_failed, jwks_fetch_blockedThe jwksUri is unreachable, or the SSRF egress guard refused it (add an internal IdP's range to SSRF_ALLOW_CIDRS, which never admits loopback or cloud metadata)
disallowed_algorithmThe JWT alg is outside the row's allowedAlgs; symmetric HS* and none are excluded by default
malformed_token, missing_required_claim, claim_too_longA claim of sub/iat/exp/iss/aud is absent, or iss exceeds 512 or sub exceeds 256 characters
Role not mappedThe claim claimMapping.role points at was never populated, or its value is neither platform nor admin. The most common cause overall
Org not boundA platform role against a row scoped to one Org, or an admin role on a global row (no orgId) when the Server holds no live Org to bind, whether none exists or the only one is deactivated; a row with an explicit orgId binds that Org regardless

The named 401. Five refusals reach the caller with reason and recoveryHint on the body instead, because a signature-valid bearer has no API-key path that could have served it: subject_not_allowlisted, jti_replayed, jti_unregistrable, ambiguous_org_binding and misconfigured_issuer (empty issuerUrl or expectedAudience, malformed jwksUri, or no expectedAzp for a multi-audience token; answered once no sibling row has served the bearer, so a row that can serve it still wins). Branch on reason, never on the prose in detail.

Where the operator looks. Four counters break the refusals out without log access: agledger_oidc_admin_org_binding_failures_total{reason} (no_org, org_deactivated, multiple_orgs, ambiguous_issuer_rows, platform_role_on_org_scoped_row, lookup_failed, scopes_claim_absent, scopes_claim_wrong_type), agledger_oidc_admin_subject_refusals_total{role}, agledger_oidc_admin_jti_replays_total{reason} and agledger_oidc_admin_jti_unenforceable_total{role}, the last of which counts bearers admitted on a single-use row that could not be enforced because the token resolved no id. On the audit stream, an accepted bearer writes auth.oidc_admin_authenticated naming the trusted_issuers row that admitted it, and a refusal writes auth.failed with payload.reason set to oidc_subject_not_allowlisted, oidc_jti_replay or oidc_jti_unregistrable, which is how a SOC joins a refusal back to the sign-in that preceded it. In the engine log, the per-candidate diagnosis is on the lines OIDC token validated but mapped.role missing or unrecognized, OIDC validation rejected token at this candidate, admin OIDC bearer refused and platform-role OIDC bearer refused.

GET /v1/admin/trusted-issuers?appliesTo=admin lists the rows and their orgId, which is where to start when a token that looks right is refused. A row an operator created is corrected with PATCH /v1/admin/trusted-issuers/{id}; a provisioning-managed row is changed in its YAML and reloaded, since PATCH answers it with 409.