Agent identity patterns in practice

Register agents in a real IdP and model the agent, its user, and its tools as distinct principals.

🪪 Module 2 9 min read Not started

Cross-ref (Microsoft stack): Module 10 covers how these patterns are implemented in Microsoft Entra Agent ID. In particular:

  • The registry + client-per-agent model → Lesson 10.2 (blueprint / blueprint principal / agent identity / agent's user account).
  • Token Exchange (RFC 8693) → Lesson 10.3 (agent OBO flow via jwt-bearer).
  • Client Credentials for service-identity agents → Lesson 10.3 (autonomous flow).
  • Dynamic Client Registration → Lesson 10.2 (Graph-based blueprint / identity provisioning).

Why this matters

Lesson 1.2 explained the what; this lesson is the how. You'll wire delegation (on-behalf-of), service identity, dynamic client registration, and the agent record into an actual stack. Pick the wrong pattern and you'll either over-share permissions or rebuild your identity layer from scratch in 6 months.

Learning objectives

  1. Provision agent identities in Keycloak / Auth0 / WorkOS / Stytch.
  2. Implement an on-behalf-of (OBO) flow using RFC 8693 Token Exchange.
  3. Use Client Credentials for service-identity agents.
  4. Register agents dynamically (RFC 7591) for per-user sub-agents.
  5. Maintain an agent registry that ties identity ↔ purpose ↔ scopes.

1. Provisioning the agent identity

Keycloak

bash
# Set up admin CLI
kcadm.sh config credentials --server https://localhost:8443 \
  --realm master --user admin --password admin

# Create the agent client (confidential, supports token exchange)
kcadm.sh create clients -r agents-dev -f - <<'JSON'
{
  "clientId": "agent.helpdesk-v1",
  "name": "Helpdesk Agent v1",
  "enabled": true,
  "protocol": "openid-connect",
  "publicClient": false,
  "serviceAccountsEnabled": true,
  "standardFlowEnabled": false,
  "directAccessGrantsEnabled": false,
  "attributes": {
    "oauth2.device.authorization.grant.enabled": "false",
    "ciba.enabled": "true",
    "token.exchange.enabled": "true"
  },
  "defaultClientScopes": ["openid","profile","tenant"],
  "optionalClientScopes": ["tickets:read","tickets:write","crm:read"]
}
JSON

Then grant the helpdesk-app (the user-facing app) permission to exchange tokens to the helpdesk-v1 agent client.

Auth0

In Auth0, an agent is a Machine-to-Machine (M2M) Application with optional Token Exchange enabled (Auth0's "Agentic Authentication" feature). Each agent gets its own application, its own grant scopes, and its own audit trail.

WorkOS / Stytch

Both have first-class Agent Identity primitives (released in 2024-25). Each agent is registered with metadata (name, purpose, owner), automatically issued workload credentials, and tied to a directory of allowed actions.

Whichever IdP you use, the rule is: one agent per logical purpose, one client/app per agent, never reuse human users' creds.


2. Service-identity (Client Credentials)

For agents that don't act for a specific user:

python
import requests

token = requests.post(
    f"{ISSUER}/protocol/openid-connect/token",
    data={
        "grant_type": "client_credentials",
        "client_id": "agent.helpdesk-v1",
        "client_secret": CLIENT_SECRET,
        "scope": "tickets:read tickets:write",
    },
    verify=False,
).json()
print(token["access_token"])

Decoded:

json
{
  "iss": "https://localhost:8443/realms/agents-dev",
  "aud": "tickets-api",
  "sub": "agent.helpdesk-v1",
  "scope": "tickets:read tickets:write",
  "exp": 1735000900
}

No sub for a user — this token is the agent acting alone. Use for ingestion, monitoring, scheduled cleanup.

Anti-pattern: many engineers default to Client Credentials for everything because it's simple. If a user authorised the work, you should be using delegation (next).


3. On-Behalf-Of (Token Exchange, RFC 8693)

Flow:

  1. User signs in to the app → app gets user_access_token (audience: app-api).
  2. App spawns the agent and gives it the user's token.
  3. Agent calls IdP's token endpoint with grant_type=urn:ietf:params:oauth:grant-type:token-exchange, presenting user_access_token as subject_token and its own client credentials.
  4. IdP returns a new token: sub = user, act.sub = agent.helpdesk-v1, narrower scope, target audience = tickets-api.
  5. Agent calls tickets-api. Logs show both subject + actor.
python
import requests, json

def exchange_for_obo(user_access_token: str, target_audience: str, scope: str) -> dict:
    r = requests.post(
        f"{ISSUER}/protocol/openid-connect/token",
        auth=("agent.helpdesk-v1", CLIENT_SECRET),
        data={
            "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
            "subject_token": user_access_token,
            "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
            "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
            "audience": target_audience,
            "scope": scope,
        },
        verify=False,
    )
    r.raise_for_status()
    return r.json()

The resulting token has act baked in:

json
{
  "iss": "https://localhost:8443/realms/agents-dev",
  "aud": "tickets-api",
  "sub": "alice@acme.com",
  "act": { "sub": "agent.helpdesk-v1" },
  "scope": "tickets:read",
  "exp": 1735001200
}

tickets-api validates as usual, plus reads act.sub for audit. Done.

Narrowing scopes

The exchange should narrow scopes, not widen them. IdPs enforce a downscope policy: requested scopes ⊆ subject token's scopes ⊆ what the agent client is allowed to request.

Always request the smallest scope sufficient for the current task. The agent runtime should know what tools it intends to call this turn and request matching scopes.

Refreshing

For long-lived agents, the user's token will expire. Two patterns:

  1. Offline access — at user sign-in, request offline_access scope; store the refresh token in your server-side vault (never on the client). Use refresh + exchange on every turn.
  2. CIBA — re-prompt the user out-of-band when sensitive action is needed. (Lesson 3.1.)

For high-risk agents prefer pattern 2 over long-lived refresh tokens. Long-lived refresh tokens are an obvious theft target.


4. Dynamic client registration (RFC 7591)

If your product spawns per-user or per-task sub-agents, registering each in Keycloak by hand doesn't scale. RFC 7591 lets a trusted bootstrap client register new clients programmatically.

python
r = requests.post(
    f"{ISSUER}/clients-registrations/openid-connect",
    headers={"Authorization": f"Bearer {INITIAL_ACCESS_TOKEN}"},
    json={
        "client_name": "agent.research-v2.task-91f3",
        "grant_types": ["client_credentials","urn:ietf:params:oauth:grant-type:token-exchange"],
        "token_endpoint_auth_method": "private_key_jwt",
        "jwks": { "keys": [JWK] },
        "scope": "tickets:read crm:read",
        "contacts": ["sec@acme.com"]
    },
    verify=False,
)
print(r.json()["client_id"])

You retain a registration access token that allows updating + deleting that client later (programmatic lifecycle).

This is exactly what the MCP authorization spec uses to onboard new MCP clients without admin intervention (lesson 4.1).

Lifecycle

  • Created when sub-agent spawns.
  • Active during the task.
  • Disabled or deleted on completion.
  • Audited (count of registrations, scopes requested, age) to detect runaway agent creation.

5. The agent registry — your source of truth

Even with IdP support, you want an internal registry that records more than the IdP can:

python
# pydantic model
class AgentRecord(BaseModel):
    agent_id: str                         # immutable
    version: str
    display_name: str
    owner_team: str
    purpose: str                          # one-paragraph plain-text
    risk_tier: Literal["low","medium","high"]
    allowed_scopes: list[str]
    allowed_tools: list[str]
    permitted_delegation: list[str]       # e.g. ["users:tenant=*", "agents:research-v2"]
    model_ref: str                        # model + system prompt version
    created_at: datetime
    deprecated_at: datetime | None
    incident_history: list[str]           # ids

Stored alongside the agent code (git-tracked YAML or DB-backed UI). Required reading at code review for any change that touches the agent's tool list.

Pair with the IdP: the registry is the business model of an agent; the IdP holds its credentials.


When a user first connects an agent ("install Helpdesk Agent for my workspace"), present a consent screen that lists:

  • Agent's name, purpose, version.
  • Tools it will call.
  • Specific scopes per tool, with plain-English explanation.
  • Data it will store.
  • How to revoke.

Use OIDC's standard consent UI when possible; otherwise build your own and back it with the same per-scope grant records. WorkOS, Auth0, and Descope all expose hooks for custom consent UIs.

Store the user's grant as a record so:

  • The agent's later token requests can be authorised against this grant.
  • The user can list + revoke all agents acting for them.
  • Audits show: who consented to what, when.

EU AI Act high-risk obligations require demonstrable, granular user awareness — a consent record is your evidence.


7. Multi-agent delegation chains

If agent.helpdesk-v1 spawns agent.research-v2 for a sub-task, you have two patterns:

A. The parent acquires a new OBO token for the sub-agent's audience and passes it.

Token: sub=alice, act=research-v2, act.act=helpdesk-v1.

Pros: cryptographically tied; downstream sees the full chain. Cons: needs IdP support for chained act (Keycloak 24+, Auth0 supports via Actions).

B. Capability tokens (lesson 6.1).

Parent issues a narrow, single-use token to the child via an internal authority service (Vault, OPA + signing, or a small "agent broker" you build). Token includes only the resource + action + ttl needed.

Use B for short, ephemeral, narrow operations; A for general delegation with full audit.


8. Hands-on lab (3 h)

  1. In Keycloak, create:
    • helpdesk-app confidential client (the web app users sign into).
    • agent.helpdesk-v1 confidential client.
    • agent.research-v2 confidential client.
    • APIs (audiences): app-api, tickets-api, crm-api.
  2. Configure token-exchange permissions: helpdesk-app may exchange to agent.helpdesk-v1; agent.helpdesk-v1 may exchange to agent.research-v2.
  3. Write Python: user logs in to helpdesk-app; app exchanges for agent.helpdesk-v1; agent exchanges for agent.research-v2; the leaf agent calls tickets-api.
  4. Inspect the final access token — act chain present? sub = user?
  5. Add an internal agents Postgres table; populate with three agent records; expose a GET /agents/me that returns the record corresponding to the calling agent.
  6. Stretch: implement Dynamic Client Registration for spawning task-scoped sub-agents.

9. Common pitfalls

  1. Reusing the same client for the app and the agent — no audit separation, no scope separation.
  2. Forgetting to narrow scopes on token exchange (you end up widening by accident if your IdP defaults are loose).
  3. Storing refresh tokens in browser localStorage — XSS = full account takeover.
  4. Hard-coding agent secrets in the agent's container image.
  5. No registry — discovering production agents by grepping logs.
  6. Treating sub-agents as "the same agent" so all audit collapses into one ID.

10. Self-check

  1. When to use Client Credentials vs OBO.
  2. What subject_token + subject_token_type mean in Token Exchange.
  3. What an act chain looks like in JSON.
  4. Why your internal agent registry exists alongside the IdP.
  5. RFC 7591 in one sentence.

11. References

  • RFC 8693 (Token Exchange).
  • RFC 7591 (Dynamic Client Registration).
  • RFC 8414 (Authorization Server Metadata).
  • Keycloak docs — Token Exchange, DCR.
  • Auth0 "Agentic AI" / "Token Exchange" docs.
  • WorkOS AgentKit docs.
  • Stytch "Agent identity primitives".
  • OpenID Federation 1.0 (for federated agent identity).

Sign in to save your progress and earn badges.