Skip to main content

Authenticating AI Agents with O2ID

· 8 min read
Maintainers

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:

  1. 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.
  2. 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 tokenActing on behalf of a human
Grantclient_credentials + private_key_jwttoken-exchange + actor_token
Subject of the minted tokenThe agent itselfThe human — unchanged throughout
What you needThe agent's own registered keyA mandate, the human's live token, and the agent's own token
Scope ceilingThe agent's allowedScopesAgent allowedScopes ∩ mandate scope ∩ human's current role scopes ∩ app allowedScopes
Use it whenNo 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.