> Markdown version of https://agledger.ai/docs/guides/authentication/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# Authentication

An agent authenticates to a Server one of two ways:

- **A long-lived API key** (`agl_…`) - minted by an operator, 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. This is the recommended path for production.

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

## 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:

```bash
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 "{\"name\":\"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:

```bash
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"
```

```json
{
  "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": null,
  "environment": "live",
  "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/agents/019fe8a3-.../capabilities", "description": "Declare which types this agent can handle" },
    { "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" }
  ]
}
```

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:

```bash
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:

```bash
curl -s -H "Authorization: Bearer $AGLEDGER_API_KEY" "$AGLEDGER_API_URL/v1/auth/me"
```

```json
{
  "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:

```json
{"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:

```json
{"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:

```bash
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"
```

```json
{
  "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`:

```bash
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"
```

```json
{
  "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):

```json
{
  "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:

- **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: true` plus the
  verification provenance and the RFC 8693 `act` delegation chain.
- **Asserted** - the caller supplied an `on_behalf_of` identity the engine did **not** verify against
  any IdP. The engine seals `validated: false` and `provenance: "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.

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

## On a FIPS host, API keys are the only path

Everything in the OIDC-cert path binds **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:

```bash
curl -s -X POST -H "Content-Type: application/json" -d @cert-request.json \
  "$AGLEDGER_API_URL/v1/auth/oidc/cert"
```

```json
{"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](/docs/install/fips/) covers the rest of that configuration.

---

*API-key path (mint → `GET /v1/auth/me` → `GET /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.*

*Reviewed for API v1.3.3 on 2026-07-20: 1.3.3 adds opt-in verdict per-actor signatures, which the
"Optional per-request attestation" section already covers generically (an agent signs each request
body via `X-Agent-Signature`); no route list or auth flow changed.*

*Validated against API v1.4.0 on 2026-08-09 (Developer Edition, Docker Compose). The API-key path was
re-run live end to end: mint, `GET /v1/auth/me`, the unauthenticated scope-profile catalogue, both
401 bodies, the 403 with `missingScopes`, and key rotation with a grace window (old and new key both
answering inside the overlap, `previousKeyDeactivatesAt` set). The OIDC cert-exchange flow still
carries over from the v0.25.4 validation; it needs a reachable IdP and did not change in 1.4.0. The
FIPS section was run against a second v1.4.0 stack installed with `--fips`, and the cert refusal
above is that Server's literal response body.*
