Authentication
An agent authenticates to a Server one of two ways:
- A long-lived API key (
agl_…), minted by an operator and carried as a Bearer token. Simplest to stand up; the credential is a stored secret you rotate. - An OIDC-bound short-lived signing certificate. The agent presents a token from your own identity provider and receives a certificate valid for minutes. Trust is anchored to your IdP, not to a stored secret. Recommended once your client can do the exchange.
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.
OIDC-cert path (recommended when an IdP already issues your workloads' identity)
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:
-
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
subis not a value you get to choose. Read thesuboff a token the IdP has just minted rather than from configuration: a Keycloak service account'ssubis 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 onPOST /v1/admin/agentsand in provisioning YAML (oidcIss/oidcSubon the agent) as well, which is where a fleet declares it. -
From a claim, with
claimMapping.agent_idas 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 carriesoidcIss/oidcSubfor a different subject is refused with403 CERT_AGENT_BINDING_MISMATCH: a bound agent exchanges only for its own subject. -
By auto-provisioning, which creates the agent instead. A row registered with
"autoProvisionAgents": trueand anautoProvisionScopeProfile(agent-full,agent-readonlyoragent-performer-only) turns the first exchange from a subject this Server has never seen into a newephemeralagent in the row's org, capped atautoProvisionMaxAgents(1000 by default). The row needs anorgId.
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:
- Engine-validated. The caller proved the delegation with an RFC 8693 token exchange against a
trusted issuer. The engine verified it against the IdP and seals
validated: trueplus the verification provenance and the RFC 8693actdelegation chain. - Asserted. The caller supplied an
on_behalf_ofidentity the engine did not verify against any IdP. The engine sealsvalidated: falseandprovenance: "asserted".
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).
| IdP | issuerUrl shape | How the workload gets a token |
|---|---|---|
| Okta | https://<tenant>.okta.com/oauth2/<authServerId> | Client-credentials grant against the authorization server's /v1/token, with aud set to your expectedAudience |
| Auth0 | https://<tenant>.auth0.com/ | Client-credentials grant with the audience parameter set to your expectedAudience |
| Entra ID | https://login.microsoftonline.com/<tenantId>/v2.0 | Client-credentials (or managed identity) token for the app registration whose URI is your expectedAudience |
| Keycloak | https://<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 Identity | https://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 account | the 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.
| IdP | Token id | What the row needs |
|---|---|---|
| Okta | jti, by default | nothing |
| Keycloak | jti, by default | nothing |
| Entra ID | uti, never jti, on both access and ID tokens | claimMapping: { "jti": "uti" } |
| Auth0 | none on the default access-token profile | switch that API to the RFC 9068 profile, which mints a jti, or leave the flag off |
| Google service account | none, under any name | cannot 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 id is spent by the first request that accepts the token, even when that request then fails
downstream on a missing scope or a
5xx. A retry needs a fresh token, so mint one per attempt rather than per operation. - The flag is inert on a token with no id, and the Server says so. A token with no
jtiand noclaimMappingjtito resolve one from is admitted on every request, exactly as if the flag were off. Every such admission incrementsagledger_oidc_admin_jti_unenforceable_totaland the engine logs one WARN per row per process naming the fix, because a row that promises single use while handing out a reusable bearer otherwise reads exactly like a row that is working: the replay counter beside it sits at a healthy zero either way. - A token whose id is empty or longer than 512 characters is refused with
401andreason: "jti_unregistrable". A row that promises single use admits nothing it cannot register.
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:
| Cause | What to check |
|---|---|
| No candidate row | The token's iss, or every value in its aud, matches no enabled row |
wrong_issuer | iss is compared as an exact string, trailing slash included |
wrong_audience | No registered row expects any audience the token carries |
wrong_azp | The token carries several audiences and its azp is missing or is not the row's expectedAzp |
expired / not_yet_valid | exp 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_key | Usually an IdP key rotation the JWKS cache has not picked up |
jwks_fetch_failed, jwks_fetch_blocked | The 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_algorithm | The JWT alg is outside the row's allowedAlgs; symmetric HS* and none are excluded by default |
malformed_token, missing_required_claim, claim_too_long | A claim of sub/iat/exp/iss/aud is absent, or iss exceeds 512 or sub exceeds 256 characters |
| Role not mapped | The claim claimMapping.role points at was never populated, or its value is neither platform nor admin. The most common cause overall |
| Org not bound | A 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.