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 living until someone revokes it.

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. For endpoint and field-level detail, see the API reference.

API-key path

1. 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:

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_URL/v1/admin/api-keys"
{
  "keyId": "019e61f1-c755-710f-93fb-820b2f4a8446",
  "apiKey": "agl_agt_E85ITAKOvk5KBDXepDWfxurjnnZtbPWky19wdoc5fxE",
  "role": "agent",
  "ownerId": "019e61d4-fbb9-780f-b110-8a64ab46920f",
  "scopeProfile": "agent-full",
  "scopes": ["records:read","records:write","completions:read","completions:write", "..."],
  "expiresAt": 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" }
  ]
}

The raw apiKey is returned in this response and only this response - it cannot be retrieved later. Capture it now. The ownerId is an agent you created via POST /v1/admin/agents; 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_URL/v1/scope-profiles"

2. 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_KEY" "$AGLEDGER_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.

3. 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 exactly 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"]}

Rotation and revocation

API keys are long-lived (expiresAt: null by default). Treat the agl_… value as a secret: store it in your secret manager, never in source.

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. 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). 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).

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. Revocation is immediate.

OIDC-cert path (recommended for production)

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. Three steps: register the issuer once, then per agent - exchange a token for a cert, and use the cert.

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_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.

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_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), carries only the scopes the IdP asserted, and expires in minutes (maxCredentialTtlSeconds, capped at 3600). The agent re-exchanges before expiry; a leaked cert is dead almost immediately.

If the exchange fails, the reason code separates a token problem from a Server-side configuration fault, so you fix the right thing. malformed_token means the JWT failed structural validation - the agent's token is the problem (confirm it is a well-formed JWS Compact Serialization). jwks_fetch_failed means the Server could not fetch or parse the IdP's JWKS endpoint: the presented JWT is well-formed, and the fault is connectivity or config - verify the trusted issuer's jwksUri is reachable from the Server and returns a valid JWKS document, not the token.

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 - 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 and send X-Agent-Signature + X-Agent-Signature-Content-Hash (Ed25519(sha256(rawBody))). The chain entry then carries the agent's own client-side attestation of that specific request, not just the cert.

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.

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 | | 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.


API-key path (mint → GET /v1/auth/meGET /v1/scope-profiles → 401/403 failure modes) re-validated live against API v1.0.0 on 2026-06-09 (Developer Edition, Docker Compose). The OIDC cert-exchange flow (register issuer → exchange → cert-bearer call) carries over from the v0.25.4 validation on 2026-05-25 using a local OIDC issuer - it needs a reachable IdP and is version-stable; POST /v1/admin/trusted-issuers was confirmed at v1.0.0 to perform OIDC discovery against the issuerUrl. The cert-exchange failure reasons (malformed_token vs jwks_fetch_failed) and the sealed on_behalf_of provenance markers (validated + provenance) were confirmed against API v1.0.3. The per-IdP issuerUrl / expectedAudience / token-acquisition details follow each provider's standard setup and should be confirmed against your live IdP.