Agent Identity API
An identity per AI agent inside a customer's realm — how to register one, what its tokens carry, and what each of the two revocation paths actually stops.
RealmSSO can give an AI agent its own identity in your customer's realm instead of letting it share a human's session or a static API key. There are two separate mechanisms, they are not interchangeable, and the difference decides which revocation you get. All of it is API-only: there is no screen for any of it in the dashboard or the Admin Portal. See Agent & MCP Identity for the product view.
| Agent client | Agent principal | |
|---|---|---|
| What it is | A confidential Keycloak client of its own, minted per named agent | An existing brokered user, flagged as acting agentically |
| How it authenticates | client_credentials with its own client secret | RFC 7523 JWT assertion signed by the customer's IdP |
| Which client it uses | its own agent-… client | the account's one org-<slug> client, alongside its humans |
| Gated by | API authorization only — not the account switch | The account switch and the per-connection switch, both off by default |
| Revocation strength | Immediate for new tokens | New tokens only; issued tokens live out their lifetime |
MCP is not part of this
Nothing on this page is MCP. RealmSSO publishes no RFC 9728 protected-resource metadata, implements no RFC 7591 dynamic client registration, performs no RFC 8693 token exchange, and brokers no outbound agent call. The registration endpoint below is RealmSSO's own API, not the RFC. MCP-shaped access is designed in ADR 0017, which is a proposal and is not built.
Agent clients
An agent client is for an agent that runs on your side and authenticates for itself. Registering one provisions a new confidential client in that account's own Keycloak realm, with its service account enabled, and attributes that service account as an agent.
Register an agent client
/api/v1/agent-clients| Field | Type | Notes |
|---|---|---|
| accountId | string | Required |
| name | string (1–255) | Required — appears in the generated client id |
| owner | string (1–255) | Required. Who is accountable for this agent; becomes the agent_owner claim. Refused if blank, because it is what makes revocation actionable |
| scopes | string[] | Optional optional-client-scopes. Omitting it grants none at all — it never defaults to everything |
Authenticate with a vendor session or an X-API-Key. A key needs the agent-clients:write scope, and the caller needs at least member on the account. The generated client id is agent-<account-slug>-<agent-slug>-<random>.
{
"data": {
"id": "…",
"accountId": "…",
"name": "nightly-reconciler",
"owner": "platform-team@example.com",
"status": "active",
"keycloakClientId": "agent-acme-nightly-reconciler-9f3c21ab",
"clientId": "agent-acme-nightly-reconciler-9f3c21ab",
"clientSecret": "…"
}
}The secret is returned exactly once
Nothing persists it — the AgentClient row has no secret column, the same rule API key creation follows. If it is lost, register a new agent client and revoke the old one.
What the registration policy fixes, and what it refuses
The client is created with a deliberately narrow shape, and none of it is caller-controlled:
- Confidential and
client_credentials-only. The standard flow, the implicit flow and direct access grants are all disabled andredirectUrisis empty, so there is no browser-facing path into it. fullScopeAllowedis alwaysfalse, and registration assigns the service account no realm or client role at all.offline_accessis refused outright, whatever the realm's own configuration says — so the credential cannot be traded for a standing refresh token.- A requested scope that the realm does not already define, or that is on that deny list, is rejected with a validation error and nothing is provisioned. It is not silently dropped, so a caller who asked for something they cannot have is told at registration rather than discovering it in production.
Registration is deliberately not idempotent: each call mints a new credential, which is what stops a retry after an ambiguous timeout from silently rotating an earlier agent's secret. Uniqueness of a logical agent name is enforced separately, by a unique constraint on (accountId, name). And if any step after the client is created fails — reading back the service account, writing the agent attribution, ensuring the claim mappers — the just-created Keycloak client is deleted before the error is returned, because a client that exists with no agent attribution is worse than no client at all: nothing downstream would know to distrust it.
List, read, revoke
/api/v1/agent-clients?accountId=…/api/v1/agent-clients/:id/api/v1/agent-clients/:id/revokeThe two reads need viewer on the account and the agent-clients:read scope; revoke needs member and agent-clients:write. Revoke is a POST and not a DELETE on purpose, and it is idempotent — revoking an already-revoked client returns the row unchanged.
The four claims
Every token minted for an agent carries four normalized claims, and a human's token carries none of them. The mappers are stock Keycloak user-attribute mappers with no conditional logic: Keycloak omits a user-attribute claim entirely when the attribute is unset, which is what makes “never on humans” true without anything to get wrong per request.
| Claim | JSON type | Meaning |
|---|---|---|
| agent | boolean | Always true when present. Typed as a boolean so your application can check token.agent === true rather than the string |
| agent_source | string | Which trust path admitted the agent. Constrained to entra, okta, scim, local, dcr |
| agent_owner | string | Who is accountable for the agent, as asserted at registration or marking |
| agent_parent | string | Upstream parent id, for attribution and revocation. Optional |
An agent client registered through the endpoint above carries agent_source: "dcr", and its agent_parent is its own client id — because disabling that client is how the agent is revoked, and nothing further upstream is known at registration time.
Access token and userinfo — not the ID token
The mappers set access.token.claim and userinfo.token.claim to true and id.token.claim to false. The ID token is the artifact most likely to be logged, cached, or handed to a browser, so the agent attribution deliberately stays out of it. No introspection claim is set either. Validate the claims on the access token, or fetch userinfo.
Two limits worth knowing before you build on this. The claim names are a convention ratified in ADR 0003, and no SDK validates them for you — checking them is your application's job. And the claims only reach a token because the four attributes are declared in the realm's User Profile; RealmSSO declares them when it ensures the mappers, but an attribute written to a realm that has not had that done is dropped by Keycloak silently, mappers and all.
Agent principals and the RFC 7523 grant
The other mechanism is for an agent the customer's identity provider vouches for. It authenticates non-interactively with a JWT that provider signed, using the RFC 7523 JWT Authorization Grant, and it is off by default at two separate grains.
The account switch
/api/v1/accounts/:id/agent-accessBody is { "enabled": boolean }. It is off on every account until someone turns it on. This route is vendor-session only — the accounts module keeps its routes in a sub-plugin that API keys cannot reach at all, so an API key cannot flip an account's agent policy no matter what scopes it holds.
The switch is asymmetric, and the difference matters operationally
Enabling touches no connection. It lifts the gate and nothing else; each connection still has to turn its own grant on.
Disabling cascades. It walks every connection on the account that currently holds the grant and revokes each one in Keycloak. That is a write per connection and it can partly fail: the account flag still flips off — an off switch one broken connection can veto is not an off switch — but the request answers 502 AGENT_ACCESS_REVOKE_INCOMPLETE and names every connection still live-granted, and each of those keeps assertionGrantEnabled: true because Keycloak genuinely still holds its grant. Re-POST enabled: false to retry; the cascade re-selects exactly the connections still granted, so it converges.
The connection switch
/api/v1/connections/:id/assertion-grantBody is { "enabled": boolean }. Needs member and, for a key, connections:write. Enabling is refused in four cases, each of them a real constraint rather than a validation formality:
- The connection is not provisioned in Keycloak yet, so there is no identity provider to enable it on.
- The connection is SAML. RFC 7523 needs a JWT bearer assertion from the customer's IdP, and a SAML IdP has no token endpoint and no JWKS to produce one — OIDC connections only.
- The account's agent-access policy is off. Turn that on first.
- The account's SSO client currently carries
offline_access. An agent principal authenticates against that same client — this product provisions exactly one per account — so enabling the grant would silently widen what an agent could hold. Turn offline access off first (see Connections API).
Disabling is never blocked by any of the gates above. Turning a protection off must always be possible.
Marking an agent principal
/api/v1/connections/:id/agent-principals| Field | Type | Notes |
|---|---|---|
| userId | string | Required — the Keycloak internal user id of a user already brokered through this connection |
| source | enum | Required — one of entra, okta, scim, local, dcr |
| owner | string | Required — becomes agent_owner |
| parent | string | Optional — becomes agent_parent |
This is an explicit, authenticated action against a user who already exists in the realm, not an automatic pipeline: nothing infers an agent from a directory sync today. It is refused on a SAML connection, refused while the connection's assertion grant is off (the claims would be ones no token could ever carry), and it verifies the user really is federated through this connection's identity provider — so one connection's endpoint cannot mint agent claims for a user who never came through it.
Revocation: two paths, two different strengths
These are not the same operation with two names, and a reader who treats them as equivalent will mis-handle an incident. RealmSSO states the difference rather than letting “revoked” imply the stronger property on both.
| Revoke an agent client | Revoke an agent principal | |
|---|---|---|
| Endpoint | POST /api/v1/agent-clients/:id/revoke | POST /api/v1/connections/:id/agent-principals/:userId/revoke |
| Mechanism | The Keycloak client is disabled | The four claim attributes are deleted from the user |
| New tokens | None. A disabled client cannot complete client_credentials at all | Still minted — they simply carry none of the agent claims |
| Tokens already issued | Unaffected; they expire normally | Unaffected; they keep agent: true until they expire |
| Sessions | Not applicable — an agent client has no user session | Not terminated. No user is logged out |
| Effective immediately? | For new tokens, yes | No. The exposure window is the realm's access-token lifetime, and the response quotes it |
Revoking an agent client disables rather than deletes it, so the client and its agent attribution stay inspectable for audit afterwards.
Revoking an agent principal returns the exposure alongside the result, because ADR 0004 makes stating the window a requirement of any surface offering the verb — silence would be read as “immediate”:
{
"data": {
"userId": "…",
"clearedClaims": ["agent", "agent_source", "agent_owner", "agent_parent"],
"wasMarked": true,
"exposure": {
"accessTokenLifespanSeconds": 300,
"accessTokenLifespanMinutes": 5,
"sessionsTerminated": false,
"issuedTokensInvalidated": false,
"statement": "This is not immediate. No token minted from now on carries the agent claims, but access tokens already issued keep them, and stay valid, for up to 300 second(s) (about 5 minute(s)), this realm's access-token lifetime. Sessions are not terminated and the user is not logged out — ending sessions is a separate, explicit operation (ADR 0004)."
}
}
}accessTokenLifespanSeconds is null when Keycloak could not be read, and the statement says so rather than substituting a plausible default. Revoking a principal is deliberately not gated on the connection being OIDC or on the assertion grant still being enabled: a principal marked while the grant was on has to stay revocable after it is turned off.
What is not built
Stated here because a rollout plan needs it before you commit, and because the parts that are absent are the ones most likely to be assumed present:
- Everything MCP. No protected-resource metadata, no standards-based dynamic client registration, no token exchange, no brokering of what an agent calls outward. ADR 0017 describes how it would work in both directions and is a proposal.
- Per-agent authorization for principals. An agent client gets a registration-time scope policy. An agent principal authenticates against the account's single
org-<slug>client alongside its humans, so there is no per-agent scope or role split for one today. - The agent as an audit actor. The audit log records the person or API key that acted on an agent —
agent_client.registered,agent_client.revoked,connection.agent_principal_marked,agent_principal.revoked— never the agent itself as the actor. See Audit Logging. - Automatic agent detection. An agent principal is flagged by an explicit API call naming a Keycloak user. Nothing infers one from SCIM or from a directory.
- Any user interface. Every endpoint on this page is API-only.
Related
Access Control for the API key scopes named here, Connections API for the connection those grants sit on, and Agent & MCP Identity for the product view.