Skip to main content
← Back to blog

Authenticating AI Agents with O2ID

· 10 min read
O2ID Team
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

# 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 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, the Client Credentials Grant, and Resource Servers.