Authenticating AI Agents with O2ID
Every AI agent eventually needs to answer two very different questions:
"who am I?" and "who am I acting for, right now?" O2ID gives an
AI agent its own identity — distinct from both a
human User and an OAuth Application — and two separate flows for those
two questions. This post walks through both, using a small agent as a
running example, and finishes by using the resulting token to call an
external API.
The example: Ada, a support agent
Ada is a support agent for a SaaS product that runs its customer identity on O2ID. It does two kinds of work:
- Overnight, unattended, it works through the backlog of open support tickets — reading free-form customer messages, judging severity and sentiment, merging obvious duplicates, drafting suggested replies for the team to review in the morning. None of this touches any one customer's account or authority; it's Ada reading and organizing its own ticket queue, as itself.
- During the day, a customer chatting live with Ada might ask it to do something to their own account — "cancel my subscription," "why was I charged twice." For that, Ada has to act as that specific customer, so the action is attributable to the person who actually asked for it, not to a bot with broad access to every account.
Those are exactly the two flows O2ID supports: own token and acting on behalf of a human.
Step 0: Register the agent and its key
AGENT_JSON=$(./o2idctl agents create --name "Ada" --scope users:read)
AGENT_INTERNAL_ID=$(printf '%s' "$AGENT_JSON" | jq -r .id)
AGENT_ID=$(printf '%s' "$AGENT_JSON" | jq -r .agentId)
Every agent has two IDs, and mixing them up is the single easiest mistake
to make here: AGENT_INTERNAL_ID (the plain id field) is for managing
the agent — agents get/update/delete, and the mandate endpoints
below. AGENT_ID (the agent_-prefixed agentId field) is the agent's
public identity — it only ever shows up inside an OAuth request, as a
client_id or a JWT iss/sub claim. Passing one where the other is
expected fails with agent not found, since they're looked up in
completely different ways.
Ada runs unattended overnight, so it authenticates with an asymmetric key instead of a shared secret (see Registering a public key). Generate a keypair and derive the JWK O2ID expects:
import base64
import json
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
def b64url(n: int, byte_len: int) -> str:
return base64.urlsafe_b64encode(n.to_bytes(byte_len, "big")).rstrip(b"=").decode()
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
numbers = private_key.public_key().public_numbers()
with open("ada-private.pem", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
with open("ada-public.jwk.json", "w") as f:
json.dump({"kty": "RSA", "n": b64url(numbers.n, 256), "e": b64url(numbers.e, 3)}, f)
./o2idctl agents update "$AGENT_INTERNAL_ID" --public-key ./ada-public.jwk.json
O2ID only ever stores the public half. The private key stays with the agent process and is used to sign a short-lived assertion on every token request.
Flow 1: getting its own token (no human involved)
For the overnight ticket-triage run, there's no human session to
borrow — Ada authenticates as itself via the
Client Credentials Grant,
proving its identity with a signed private_key_jwt instead of a client
secret:
import time
import jwt # PyJWT
import requests
O2ID_URL = "https://your-o2id-host"
with open("ada-private.pem", "rb") as f:
private_key_pem = f.read()
assertion = jwt.encode(
{
"iss": AGENT_ID,
"sub": AGENT_ID, # RFC 7523 requires iss == sub
"aud": f"{O2ID_URL}/oauth2/token",
"exp": int(time.time()) + 120,
},
private_key_pem,
algorithm="RS256",
)
resp = requests.post(f"{O2ID_URL}/oauth2/token", data={
"grant_type": "client_credentials",
"client_id": AGENT_ID,
"client_assertion": assertion,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"scope": "users:read",
})
agent_token = resp.json()["access_token"]
agent_token's sub claim is Ada's own agentId, scoped only to what
the agent itself was granted (users:read here) — there's no mandate, no
act claim, and no human role scope in this path at all. Ada uses it to
cross-reference which O2ID customer record each ticket belongs to and
check account standing while triaging — read-only work done as itself,
against no one customer's authority in particular.
Checking that claim: inspecting the token
agent_token isn't a JWT — it's an opaque random string, so there's
nothing to decode locally the way you'd decode a JWT on jwt.io. To
actually see the sub claim (or anything else) it carries, use
token introspection
(POST /oauth2/introspect, RFC 7662). It requires the tokens:introspect
scope — an operator/admin credential, not something the agent itself needs:
curl -s -X POST "$O2ID_URL/oauth2/introspect" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "token=$agent_token"
{
"active": true,
"scope": "users:read",
"client_id": "agent_e241a1c4447bdc04d755a707dd0dec76",
"sub": "agent_e241a1c4447bdc04d755a707dd0dec76",
"token_type": "Bearer",
"exp": 1735689600
}
That's it — sub is Ada's own agentId, exactly as described above, and
there's no act field at all. Introspecting an unknown, expired, or
malformed token never errors — it's always a 200 with just
{"active": false}, so this same call is also the right way to check
"is this token still good?" before using it.
Flow 2: acting on behalf of a human
Maya, a customer, is live in a chat with Ada right now, asking it to cancel her subscription. Because Maya is present and is authorizing exactly this one action, Ada's backend doesn't need any mandate set up in advance — it creates one on the spot, scoped tightly and expiring in a few minutes, and redeems it immediately, in the same round trip:
curl -X POST "$API_URL/agents/$AGENT_INTERNAL_ID/mandates" \
-H "Authorization: Bearer $MAYA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope": ["users:read"], "expiresAt": "2026-07-22T09:15:00Z"}'
A mandate created this way is no different from one set up days ahead —
same record, same lookup at redemption time — only the timing differs.
(This requires Maya's own role to include the mandates:manage scope;
since a mandate can only ever be created for the caller themself, that's
safe to grant to every ordinary customer, not just administrators.)
With the mandate in place — even by a few seconds — Ada redeems it
through Token Exchange.
Maya's token is the subject_token; the agent's own token from Flow 1
is the actor_token:
resp = requests.post(f"{O2ID_URL}/oauth2/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": FRONT_DOOR_CLIENT_ID,
"subject_token": maya_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"actor_token": agent_token,
"actor_token_type": "urn:ietf:params:oauth:token-type:access_token",
"scope": "users:read",
})
delegated_token = resp.json()["access_token"]
There's no mandate_id to pass — O2ID looks the mandate up implicitly by
the (agent, human) pair named by actor_token and subject_token. The
minted token's subject stays Maya; Ada is recorded separately in an
internal act claim. Every lookup this token makes is auditable back to
Maya, and the moment her role is revoked, this exchange (and any future
one) stops working — no separate cleanup needed.
The granted scope is the intersection, checked fresh at exchange time, of:
the front-door app's allowedScopes, Ada's own allowedScopes, the
mandate's scope, and Maya's current role scopes.
Introspecting delegated_token the same way as before now shows the
difference: sub is Maya, and an act field appears naming Ada —
{
"active": true,
"scope": "users:read",
"sub": "9f8e7d6c5b4a39281726354433221100",
"act": { "sub": "agent_e241a1c4447bdc04d755a707dd0dec76" }
}
— which is the whole point of RFC 8693 §4.1's actor claim: the audit trail always shows who's really behind an action, not just whoever's holding the token.
Using the token to call an external API
With delegated_token in hand, Ada calls the external billing API to
actually process the cancellation. It passes resource on the exchange
so the intended target is recorded on the token:
resp = requests.post(f"{O2ID_URL}/oauth2/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": FRONT_DOOR_CLIENT_ID,
"subject_token": maya_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"actor_token": agent_token,
"actor_token_type": "urn:ietf:params:oauth:token-type:access_token",
"scope": "users:read",
"resource": "https://billing.example.com",
})
delegated_token = resp.json()["access_token"]
requests.post(
"https://billing.example.com/v1/subscriptions/cancel",
headers={"Authorization": f"Bearer {delegated_token}"},
json={"reason": "customer_requested"},
)
Worth being upfront about: today resource is
recording-only — O2ID validates
it's a well-formed URI and stamps it on the token, but there's no
resource-server registry yet, so billing.example.com can't independently
verify the token was minted for it. Real cross-service verification is
follow-up work; for now, treat it as an audit trail, not an enforcement
boundary.
Own token vs. acting on behalf of a human
| Own token | Acting on behalf of a human | |
|---|---|---|
| Grant | client_credentials + private_key_jwt | token-exchange + actor_token |
| Subject of the minted token | The agent itself | The human — unchanged throughout |
| What you need | The agent's own registered key | A mandate, the human's live token, and the agent's own token |
| Scope ceiling | The agent's allowedScopes | Agent allowedScopes ∩ mandate scope ∩ human's current role scopes ∩ app allowedScopes |
| Use it when | No human is present (background jobs, cron) | A specific human is asking for something, present or not — the mandate can be created on the spot or looked up from one made in advance |
Both flows end in the same place — an O2ID access token an agent can present to any API — but they answer different questions about whose authority that token carries. Getting that distinction right up front is what makes the audit trail (and the ability to instantly cut an agent off by revoking a role or a mandate) actually hold up.
For the full mechanics, see Managing AI Agents, Token Exchange, and the Client Credentials Grant.