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
# every non-health endpoint is tenant-scoped; o2idctl and a fresh server. Both default to the seeded "system" tenant (see /docs/tenancy)
O2ID_URL = "https://your-o2id-host/t/system"
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.
A note on inspecting this 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. Normally
token introspection
(POST /oauth2/introspect, RFC 7662) is how you'd see a claim like sub
server-side instead. But introspection is scoped to registered
resource servers: the only caller who
can ever introspect a token is an application's own client_credentials
token, and only for a target token whose aud names a resource server
that application is authorized for — there's no admin/operator scope for
this anymore. agent_token was minted for O2ID's own API, not an
external resource server, so it has no aud at all, and a token with no
aud can't be introspected by anyone. That's expected, not a gap to work
around — we'll see a token that genuinely can be introspected once Ada
calls the external billing API, below.
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 "$O2ID_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.
Unlike agent_token, delegated_token's sub is Maya, and it carries an
internal act claim naming Ada — {"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. This particular exchange didn't pass resource, so this token has
no aud either and, per the note above, isn't introspectable yet; below,
once Ada calls the external billing API with resource set, we mint a
version of this same delegation that is.
Using the token to call an external API
Before resource does anything on the exchange, billing.example.com
has to be a registered resource server,
and the calling application — FRONT_DOOR_CLIENT_ID — has to be
authorized for it; anything else is rejected outright with
invalid_target. It's also worth giving the billing side its own O2ID
identity, distinct from the front-door app, so it can verify tokens
presented to it later instead of just trusting whatever Authorization
header shows up. And like an agent's id vs. agentId, an application
has both an internal id — what authorize-resource-server takes below —
and a clientId — what goes in an OAuth request; $FRONT_DOOR_APP_ID
here is the front-door application's internal id, alongside the
$FRONT_DOOR_CLIENT_ID already used above:
RS_JSON=$(o2idctl resource-servers create --name "Billing API" --identifier https://billing.example.com)
RS_ID=$(printf '%s' "$RS_JSON" | jq -r .id)
BILLING_CLIENT_SECRET=$(openssl rand -hex 32)
BILLING_APP_JSON=$(o2idctl applications create --name "Billing API" --client-secret "$BILLING_CLIENT_SECRET")
BILLING_APP_ID=$(printf '%s' "$BILLING_APP_JSON" | jq -r .id)
BILLING_CLIENT_ID=$(printf '%s' "$BILLING_APP_JSON" | jq -r .clientId)
o2idctl applications authorize-resource-server "$FRONT_DOOR_APP_ID" "$RS_ID"
o2idctl applications authorize-resource-server "$BILLING_APP_ID" "$RS_ID"
With that in place, Ada calls the external billing API to actually process
the cancellation. It passes resource on the exchange so the intended
target is recorded — and now actually enforced — 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"},
)
Verifying the token, from the billing side
This used to be the gap: resource only recorded an audit trail, and
billing.example.com had no way to independently confirm a token was
really minted for it. Now it can, using the Billing API identity
registered above, via
token introspection
(POST /oauth2/introspect, RFC 7662):
BILLING_TOKEN=$(curl -s -X POST "$O2ID_URL/oauth2/token" \
-d "grant_type=client_credentials&client_id=$BILLING_CLIENT_ID&client_secret=$BILLING_CLIENT_SECRET" \
| jq -r .access_token)
curl -s -X POST "$O2ID_URL/oauth2/introspect" \
-H "Authorization: Bearer $BILLING_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "token=$delegated_token"
{
"active": true,
"scope": "users:read",
"client_id": "front_door_client_id_here",
"sub": "9f8e7d6c5b4a39281726354433221100",
"aud": "https://billing.example.com",
"act": { "sub": "agent_e241a1c4447bdc04d755a707dd0dec76" },
"token_type": "Bearer",
"exp": 1735689600
}
sub is Maya, not Ada, and act still names Ada — RFC 8693 §4.1's audit
trail holds all the way to the edge of the system, not just inside O2ID.
The only caller who can ever introspect a token this way is an
application's own client_credentials token, and only for a target whose
aud names a resource server that application is authorized for — being
authorized to be named as a target already carries the right to check
tokens minted for it, so there's no separate admin scope needed.
Introspecting an unknown, expired, or not-authorized-for-this-caller token
never errors — it's always a 200 with just {"active": false}, so this
same call also doubles as "is this token still good?" before relying on
it.
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, the Client Credentials Grant, and Resource Servers.