For apps
Services that want agents to authenticate on behalf of users publish
discovery metadata and implement the /agent/identity registration
endpoint plus the standard OAuth /oauth2/token and /oauth2/revoke
endpoints described here. There are two flows to choose from — agent
verified and user claimed — and a service may support either or both.
Publish auth.md
An auth.md is a short Markdown document, hosted at the root of your
service (typically https://service.example.com/auth.md), that tells agents —
human and LLM readers alike — how to register against your service, which
flows you support, and what scopes they can ask for. It's a
discovery-friendly companion to the structured Protected Resource Metadata (PRM): agents that find you
through documentation, SDKs, or web search can read it directly without
having to probe for 401 responses.
The PRM (.well-known/oauth-protected-resource) stays the machine-readable
source of truth; auth.md is the prose summary that points back at it.
What to include
- Service name and a one-line description.
- Which registration types you support (
identity_assertion,service_auth,anonymous) and any constraints. - Links to the PRM and the
/agent/identityendpoint. - Scope inventory with a one-line description per scope.
- Pointers to pricing, terms of service, and privacy policy.
- A contact channel for integration issues.
See the example auth.md for a working template.
The two flows
- Agent verified. Trusted agent providers (OpenAI, Anthropic, Cursor, etc.)
assert a user's identity with an
ID-JAG.
The service verifies the assertion and returns a service-signed
identity_assertionfor the matched user synchronously. - User claimed. The user authorizes the agent by confirming a code
on a page your service owns — no agent-provider participation required.
The service can be configured for one of two entrypoints:
- Anonymous start. The agent self-registers without an identity
and receives a pre-claim
identity_assertionimmediately. The agent (or user) can run the claim ceremony at any time before the registration expires to bind the registration to a real user and upgrade scopes. - Email (
service_auth). The agent supplies a user email at registration as alogin_hint. No assertion is issued until the user completes the claim ceremony. Use this when pre-claim usage is unacceptable.
- Anonymous start. The agent self-registers without an identity
and receives a pre-claim
Every path through registration returns a service-signed
identity_assertion, never a credential directly — the agent exchanges
that assertion at /oauth2/token for an access_token. The user claimed
flow drives a RFC 8628
device-authorization-shaped claim ceremony: the service mints a 6-digit
user_code, the agent surfaces it to the user, the user signs in on a
service-owned page and types the code, and the agent polls /oauth2/token
until the ceremony completes. The service never emails a code.
Minimum implementation
- Publish
.well-known/oauth-protected-resource(resource +authorization_servers) and.well-known/oauth-authorization-server(top-level OAuth endpoints +agent_authblock). - Return
WWW-Authenticate: Bearer resource_metadata="..."on 401 responses. - Host a
/agent/identityendpoint (and its/claimsub-endpoint) that dispatches ontypeand returns a service-signedidentity_assertion. - Host
/oauth2/token(RFC 7523 JWT-bearer) to exchange the assertion for an access_token, plus the claim-grant poll for the user claimed flow. - Host
/oauth2/revoke(RFC 7009) for agent-initiated credential revocation. - Accept provider-initiated Security Event Tokens
(RFC 8417) at the
advertised
events_endpoint. - For agent verified: maintain a trust list of agent providers and verify ID-JAG signatures against the provider's JWKS.
- For user claimed: implement the claim ceremony at
/agent/identity/claimand a service-owned page where the signed-in user types theuser_code. - Record audit events for every state change in the flow.
Publishing the discovery documents
Discovery is split in two: the Protected Resource Metadata at
/.well-known/oauth-protected-resource (per
RFC 9728) advertises the
resource and points at the Authorization Server, and the Authorization
Server metadata at /.well-known/oauth-authorization-server carries the
top-level OAuth endpoints and the agent_auth block describing supported
flows.
Protected Resource Metadata
{
"resource": "https://api.service.example.com/",
"resource_name": "Service",
"resource_logo_uri": "https://service.example.com/logo.png",
"authorization_servers": ["https://auth.service.example.com/"],
"scopes_supported": ["api.read", "api.write"],
"bearer_methods_supported": ["header"]
}Authorization Server metadata
{
"resource": "https://api.service.example.com/",
"authorization_servers": ["https://auth.service.example.com/"],
"scopes_supported": ["api.read", "api.write"],
"bearer_methods_supported": ["header"],
"issuer": "https://auth.service.example.com",
"token_endpoint": "https://auth.service.example.com/oauth2/token",
"revocation_endpoint": "https://auth.service.example.com/oauth2/revoke",
"grant_types_supported": [
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:workos:agent-auth:grant-type:claim"
],
"agent_auth": {
"skill": "https://service.example.com/auth.md",
"identity_endpoint": "https://auth.service.example.com/agent/identity",
"claim_endpoint": "https://auth.service.example.com/agent/identity/claim",
"events_endpoint": "https://auth.service.example.com/agent/event/notify",
"identity_types_supported": ["anonymous", "identity_assertion", "service_auth"],
"identity_assertion": {
"assertion_types_supported": [
"urn:ietf:params:oauth:token-type:id-jag"
]
},
"events_supported": [
"https://schemas.workos.com/events/agent/auth/identity/assertion/revoked"
]
}
}The top-level issuer / token_endpoint / revocation_endpoint /
grant_types_supported are standard
RFC 8414 /
RFC 7009 /
RFC 7523 fields. The
agent_auth block is the profile extension for the agent-auth–specific
surface: the registration endpoint, the claim ceremony, and the
RFC 8935 SET receiver.
The identity types map to the two conceptual flows: identity_assertion
(with the id-jag assertion type) is the agent verified flow;
anonymous and service_auth are the two entrypoints of the
user claimed flow. Advertise only what your service actually accepts.
401 with WWW-Authenticate
On any 401 from your API, include the discovery hint so agents can bootstrap:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.service.example.com/.well-known/oauth-protected-resource"User matching and JIT provisioning
When a registration resolves to a real user, decide which of your users it represents. The agent verified flow does this when an ID-JAG arrives; the user claimed flow does it when the signed-in user completes the ceremony. Recommended resolution order:
- Delegation record match. If you've previously issued an assertion
for this
(iss, sub), route to the same user. This is the strongest identifier — it's what the provider considers stable. Clean match. - Verified email/phone match → step-up. If a user exists with the
same verified email or phone but no
(iss, sub)delegation, don't bind silently. Trigger the first-link step-up ceremony — the user must confirm linking the provider identity to their account. Without this gate, any trusted provider could mint an ID-JAG asserting a victim's email and take over their account. - No match → JIT. Create a new user per your provisioning policy, or refuse if your product requires manual onboarding.
Reject ID-JAGs with neither a verified email nor a verified phone — there's no basis for matching.
Agent verified flow
A trusted agent provider signs an ID-JAG asserting the user's identity.
Your service verifies the assertion and returns a service-signed
identity_assertion synchronously, which the agent exchanges at
/oauth2/token for an access_token. Skip this section if you're only
implementing the user claimed flow.
Sequence
sequenceDiagram
actor User
participant Agent
participant Provider as Agent Provider
participant Service
Agent->>Service: GET /api/resource
Service-->>Agent: 401 Unauthorized<br/>WWW-Authenticate: Bearer resource_metadata="..."
Agent->>Service: GET /.well-known/oauth-protected-resource
Service-->>Agent: 200 OK (PRM with authorization_servers)
Agent->>Service: GET /.well-known/oauth-authorization-server
Service-->>Agent: 200 OK (AS metadata with agent_auth block)
Agent->>User: Consent to assert identity to audience?
User-->>Agent: Consent granted
Agent->>Provider: Request audience-specific ID-JAG
Provider-->>Agent: 200 OK (ID-JAG)
Agent->>Service: POST /agent/identity<br/>{ type: identity_assertion, assertion: ID-JAG }
Service->>Provider: GET /.well-known/jwks.json
Provider-->>Service: 200 OK (JSON Web Key Set)
Service->>Service: Verify signature + claims, match user
Service-->>Agent: 200 OK (identity_assertion)
Agent->>Service: POST /oauth2/token<br/>grant_type=jwt-bearer&assertion=...
Service-->>Agent: 200 OK (access_token)POST /agent/identity handler
All /agent/identity requests share the same base shape and dispatch on
the type field. Agent verified registrations come in as
type: identity_assertion with the ID-JAG assertion type.
POST /agent/identity HTTP/1.1
Host: auth.service.example.com
Content-Type: application/json{
"type": "identity_assertion",
"assertion_type": "urn:ietf:params:oauth:token-type:id-jag",
"assertion": "eyJhbGc..."
}Implementation steps
- Decode the ID-JAG header to obtain
kidandalg. - Look up the issuer (
iss) in your trusted providers list. Reject if unknown. - Fetch JWKS from the provider (see Verifying ID-JAGs for caching).
- Verify the signature using the key matching
kid. - Validate claims:
audmatches your auth server;expis future;iatis not unreasonably future;jtihas not been seen recently;client_idresolves to a known provider identity; at least one ofemail_verifiedorphone_number_verifiedistrue;auth_timeis present and within your max-age window (see auth_time freshness). - Match or provision the user (see
User matching and JIT provisioning).
If the match resolves to an existing user by email/phone but no
(iss, sub)delegation exists yet, step up (see First-link step-up) — do not silently bind. - Mint a service-signed
identity_assertion(typedoauth-id-jag+jwt, signed by your AS key,sub= the registration ID). This is what the agent exchanges at/oauth2/token.
Clean-match response:
{
"registration_id": "reg_...",
"registration_type": "identity_assertion",
"identity_assertion": "<service-signed JWT>",
"assertion_expires": "2026-05-04T13:00:00.000Z",
"scopes": ["api.read", "api.write"]
}The agent then POSTs the identity_assertion to /oauth2/token to obtain
an access_token (see The token endpoint). No
credential is issued at /agent/identity itself.
{ "error": "invalid_audience", "message": "..." }Supported error codes: invalid_issuer, invalid_signature, expired,
replay_detected, invalid_audience, invalid_client_id,
missing_verified_email, invalid_request, plus the two 401s below.
auth_time freshness
Reject ID-JAGs whose auth_time is missing or older than your configured
max-age (default 1h) with HTTP 401 and a login_required body:
WWW-Authenticate: AgentAuth error="login_required", max_age="3600", error_description="..."{ "error": "login_required", "error_description": "...", "max_age": 3600 }The agent's recourse is to refresh the user's authentication at its
provider (prompt=login or equivalent) and mint a fresh ID-JAG. Nothing
the user does at your service helps — that's why this is distinct from
step-up. Apply the check universally (even on (iss, sub) pairs you
already have a delegation for) to prevent indefinite session
piggy-backing.
First-link step-up
When the matcher finds an existing user by verified email/phone but no
(iss, sub) delegation yet, do not silently bind. Return HTTP 401 with a
claim block — the user has to confirm linking the provider identity to
their account:
WWW-Authenticate: AgentAuth error="interaction_required", error_description="..."{
"error": "interaction_required",
"error_description": "...",
"registration_id": "reg_...",
"registration_type": "identity_assertion",
"claim_url": "/agent/identity/claim",
"claim_token": "clm_...",
"claim_token_expires": "...",
"post_claim_scopes": ["api.read", "api.write"],
"claim": {
"user_code": "123456",
"expires_in": 600,
"verification_uri": "...",
"interval": 5
}
}The claim block is the same shape as the user claimed flow
(the claim ceremony). The /claim page renders
provider-aware copy for ID-JAG registrations ("Acme is asking to link
this account…") — the provider display name comes from your trust list,
never from a value the provider sets directly. After completion, the
agent's next poll picks up the bound delegation, and the next presentation
of an ID-JAG for the same (iss, sub, aud) is accepted directly.
Why step up. Without it, any trusted provider could mint an ID-JAG with
email_verified: trueforvictim@example.comand silently take over that account. Step-up gates the binding on the user being signed in at your service — their authenticated session is what authorizes the link. The claim ceremony is also where you enforce the policies you already apply to interactive sign-in (enterprise SSO, MFA, bot detection): the agent presents an ID-JAG, but the user authenticates through your own/login, so ID-JAGs never bypass your domain-bound policies.
Verifying ID-JAGs
A compliant ID-JAG header is { "typ": "oauth-id-jag+jwt", "alg", "kid" }.
The body includes iss, sub, aud, client_id, jti, iat, exp,
auth_time, and identity claims like email / email_verified. See the
provider guide for the full shape.
Trust list. Maintain a registry of providers whose assertions you
accept. A minimum entry is an issuer URL; richer entries pin a
service-controlled display_name (rendered on the step-up confirmation
page), a JWKS URI, a CIMD URL, or an attestation policy (e.g. requires
mfa in amr). Treat this list as security-critical configuration —
compromising a trusted provider means compromising every delegation routed
through them. Never render a client_name the provider sets directly; the
service decides what shows on its own UI.
JWKS fetching. Fetch {iss}/.well-known/jwks.json on first use and
cache per the response's Cache-Control, with a sane floor (e.g. 10
minutes) and ceiling (e.g. 24 hours). On kid cache miss, refetch once
before rejecting — this handles provider key rotation gracefully.
CIMD resolution. If client_id is a URL rather than an opaque
identifier, fetch it as an
OAuth Client ID Metadata Document
and verify its jwks_uri matches the one you used to verify the
signature. This decouples the provider's identity from their signing
keys so rotation doesn't churn your trust list.
Replay protection. Keep a cache of seen jti values with a TTL of
at least exp - iat plus clock skew (a 5-minute assertion + 1 minute of
skew → 6 minutes of cache). Redis, Memcached, or an indexed database
table with a TTL column all work. Reject on collision with
replay_detected.
Clock skew. Accept iat up to ~1–2 minutes in the future to
accommodate drift between provider and consumer clocks.
Revocation
Providers notify you of upstream identity events by POSTing a
Security Event Token to
the events_endpoint advertised in your discovery document, per
RFC 8935 push delivery:
POST /agent/event/notify HTTP/1.1
Host: auth.service.example.com
Content-Type: application/secevent+jwt
{ "typ": "secevent+jwt", "alg", "kid" }
.
{
"iss": "https://api.agent-provider.example.com",
"sub": "<opaque user identifier>",
"aud": "https://auth.service.example.com",
"jti": "<unique identifier>",
"iat": <epoch seconds>,
"events": {
"https://schemas.workos.com/events/agent/auth/identity/assertion/revoked": {}
}
}On receipt
- Verify the SET signature against the issuer's JWKS (same trust path as ID-JAG verification).
- Validate
issagainst the trust list,audagainst your service, and enforcejtiuniqueness for replay protection. - Dispatch on each entry in the
eventsclaim — for theidentity-assertion-revokedschema, find all credentials issued for(iss, sub, aud)and invalidate them. Unknown schemas can be ignored (RFC 8417 §2.2). - Return 202 Accepted on success (no body); on failure, 400 with
{ "err": "<code>", "description": "..." }per RFC 8935 §2.4.
This events_endpoint is distinct from the top-level revocation_endpoint:
the latter (RFC 7009) is
for the agent or an admin to kill a single bearer credential by value; the
former is for the provider to invalidate the registration itself. Expect to
extend this surface with richer
SET /
CAEP / RISC events,
delivered over this same push channel.
User claimed flow
The user authorizes the agent by signing in to your service and confirming
a 6-digit user_code the agent surfaces to them. Choose one or both
entrypoints: anonymous start (issues a pre-claim assertion up front, claim
later) or service_auth (no assertion until the ceremony completes). Skip
this section if you're only implementing agent verified.
Sequence
Anonymous start
sequenceDiagram
actor User
participant Agent
participant Service
Agent->>Service: POST /agent/identity<br/>{ type: anonymous }
Service->>Service: Create registration, mint pre-claim assertion, claim record
Service-->>Agent: 200 OK (identity_assertion, claim_token)
Agent->>Service: POST /oauth2/token<br/>grant_type=jwt-bearer&assertion=...
Service-->>Agent: 200 OK (access_token, pre-claim scope)
Note over Agent: Agent operates with pre-claim scopes
User-->>Agent: Wants to take ownership
Agent->>Service: POST /agent/identity/claim<br/>{ claim_token, email }
Service-->>Agent: 200 OK (claim_attempt: user_code, verification_uri)
Agent-->>User: Surface user_code + verification_uri
User->>Service: GET verification_uri (signs in, lands on /claim)
User->>Service: Submit user_code on the claim page
Service-->>User: 200 OK (claim confirmed)
loop until claimed
Agent->>Service: POST /oauth2/token<br/>grant_type=...:claim&claim_token=...
Service-->>Agent: 200 OK (post-claim access_token + v2 assertion) | authorization_pending
endEmail (service_auth)
sequenceDiagram
actor User
participant Agent
participant Service
Agent->>Service: POST /agent/identity<br/>{ type: service_auth, login_hint: email }
Service-->>Agent: 200 OK (claim_token, claim: user_code, verification_uri)
Agent-->>User: Surface user_code + verification_uri
User->>Service: GET verification_uri (signs in as asserted email, lands on /claim)
User->>Service: Submit user_code on the claim page
Service-->>User: 200 OK (claim confirmed)
loop until claimed
Agent->>Service: POST /oauth2/token<br/>grant_type=...:claim&claim_token=...
Service-->>Agent: 200 OK (access_token + identity_assertion) | authorization_pending
endPOST /agent/identity handlers
Same base endpoint as agent verified — dispatch on type. The user
claimed flow has two request shapes.
POST /agent/identity HTTP/1.1
Host: auth.service.example.com
Content-Type: application/jsonAnonymous start · type: anonymous
Mint a pre-claim identity_assertion immediately. The registration
carries a claim_token that can start the claim ceremony at any point
before the registration expires.
{ "type": "anonymous" }Implementation steps
- Apply rate limits (see Rate limiting).
- Create the registration. The principal it eventually binds to is up to the service — a user, workspace, account, tenant, or organization. Flag it as agent-created so downstream events and UI can distinguish it.
- Generate a claim token (prefixed, high-entropy — e.g.
clm_+ 25 chars base62). Store only its SHA-256 hash. Return the plaintext exactly once. - Mint a service-signed
identity_assertionbound to the registration. At/oauth2/tokenexchange time, unclaimed anonymous registrations get the pre-claim scope set. - Schedule an expiration job at the registration's TTL to mark the claim expired.
{
"registration_id": "reg_01ABC123DEF456GHI789JKL0MN",
"registration_type": "anonymous",
"identity_assertion": "<service-signed JWT>",
"assertion_expires": "2026-05-04T13:00:00.000Z",
"pre_claim_scopes": ["api.read"],
"claim_url": "/agent/identity/claim",
"claim_token": "clm_abc123def456ghi789jkl012mno",
"claim_token_expires": "2026-04-22T12:34:56.789Z",
"post_claim_scopes": ["api.read", "api.write"]
}Email · type: service_auth
Take a user email as a login_hint, return the ceremony materials, and
withhold the assertion until the ceremony completes. No pre-claim usage is
possible.
{
"type": "service_auth",
"login_hint": "user@example.com"
}Implementation steps
- Create a registration row marked as
service_authand persist the asserted email asclaim_email. - Generate a
claim_token(returned to the agent), aclaim_attempt_token(embedded inverification_uri), and a 6-digituser_code. Store SHA-256 hashes of all three; return the plaintextclaim_tokenanduser_code, embed theclaim_attempt_tokeninverification_uri. - Return the claim handles + a
claimblock — but noidentity_assertion. The assertion is minted when the user completes the ceremony and the agent polls/oauth2/tokenwith the claim grant.
{
"registration_id": "reg_01ABC...",
"registration_type": "service_auth",
"claim_url": "/agent/identity/claim",
"claim_token": "clm_abc123...",
"claim_token_expires": "2026-04-22T12:34:56.789Z",
"post_claim_scopes": ["api.read", "api.write"],
"claim": {
"user_code": "123456",
"expires_in": 600,
"verification_uri": "https://auth.service.example.com/login?return_to=%2Fclaim%3Fclaim_attempt_token%3D...",
"interval": 5
}
}The claim ceremony
Both entrypoints funnel into the same ceremony: the service mints a
user_code, the agent surfaces it to the user with a verification_uri,
the user signs in to the service and types the code on a service-owned
form, and the agent polls for completion. The fields (user_code,
verification_uri, expires_in, interval) borrow from
RFC 8628 device authorization,
and polling happens at the standard token_endpoint with a
profile-specific grant (urn:workos:agent-auth:grant-type:claim). The
difference between entrypoints is where the ceremony block is returned
and what the claim grant returns:
| Entrypoint | Ceremony block returned at | Claim grant returns |
|---|---|---|
| Anonymous start | /agent/identity/claim (claim_attempt) | Token response + a v2 assertion (the v1 was pre-claim; v2 carries the email) |
Email (service_auth) | /agent/identity (claim) | Token response + the first assertion (none was issued at registration time) |
POST /agent/identity/claim · start the claim (anonymous only)
Anonymous-only. service_auth registrations skip this — their claim
block is bundled into the /agent/identity registration response.
{
"claim_token": "clm_abc123...",
"email": "user@example.com"
}{
"registration_id": "reg_01ABC...",
"claim_attempt_id": "cla_01XYZ...",
"status": "initiated",
"expires_at": "2026-05-04T12:10:00.000Z",
"claim_attempt": {
"user_code": "123456",
"expires_in": 600,
"verification_uri": "https://auth.service.example.com/login?return_to=%2Fclaim%3Fclaim_attempt_token%3D...",
"interval": 5
}
}Implementation notes
- Hash the incoming
claim_tokenand look up the registration. Reject if not found (invalid_claim_token), already claimed (claimed_or_in_flight), or expired (claim_expired). - Record
claim_emailon the registration so the claim page can enforce the binding. Theemailbinds the registration to the human the agent acts for — only that signed-in user can complete the ceremony. Without it, a third party who intercepts theuser_codecould claim the agent on their own account. - Mint a
claim_attempt_tokenand auser_code; store SHA-256 hashes of both, return the plaintexts. A freshclaim_attempt_idis minted on each attempt (including same-email retries) and the previous URL stops working. - The
verification_urishould route through your sign-in flow first, so the user authenticates before the claim page can identify them.
The user-facing claim page
The user opens verification_uri, signs in to the service, and lands on a
page that:
- Resolves the registration via
claim_attempt_token(from the URL). - Verifies the signed-in user matches
registration.claim_emailif set — rejects mismatches. - Renders a form that POSTs
claim_attempt_token+ the typeduser_codeto a service-owned form-action endpoint. - On the form post, verifies the
user_codeagainst the registration's stored hash and marks the claim complete. The same-account check applies again on submit.
This is a service-owned UX surface — agents never see it. It's also where you enforce whatever policies you apply to interactive sign-in (SSO, MFA, terms re-acceptance).
POST /oauth2/token (claim grant) · agent poll
The agent polls the standard token_endpoint with the profile-specific
grant:
POST /oauth2/token HTTP/1.1
Host: auth.service.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:workos:agent-auth:grant-type:claim
&claim_token=<clm_...>While the user hasn't completed the ceremony:
{ "error": "authorization_pending", "error_description": "..." }On completion, a standard OAuth token response extended with
identity_assertion and assertion_expires:
{
"access_token": "<post-claim access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api.read api.write",
"identity_assertion": "<service-signed JWT>",
"assertion_expires": "2026-05-04T13:00:00.000Z"
}Implementation notes
- Look up the registration by
sha256(claim_token). Absent orstatus === "expired"→expired_token. Not yetclaimed→authorization_pending. Claimed → mint a fresh access_token and a freshidentity_assertion. - For anonymous, on completion revoke the pre-claim access_tokens
from earlier jwt-bearer exchanges — the canonical credential is the one
returned here. The v2
identity_assertioncarries the now-knownemail/email_verifiedclaims; the v1 the agent held has neither. - For
service_auth, this is the firstidentity_assertionfor the registration — the agent uses it for jwt-bearer refreshes once the returned access_token expires. - Honor RFC 8628's
interval— return{ "error": "slow_down" }if the agent polls faster than advertised. When theuser_codeor outer claim window closes, returnexpired_token; the agent re-calls/agent/identity/claimfor a fresh code. - Emit
claim.confirmed(see Recommended audit events).
The token endpoint
The /oauth2/token endpoint handles two grants, dispatched on
grant_type: the claim grant above, and the
RFC 7523 JWT-bearer grant
that exchanges a service-signed identity_assertion for an access_token.
POST /oauth2/token HTTP/1.1
Host: auth.service.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<identity_assertion>
&resource=https://api.service.example.com/Verify the assertion against your AS signing key (typ: oauth-id-jag+jwt, iss and aud equal to your AS, valid exp, sub
resolving to a registration), then issue an access_token scoped per the
registration's state — pre-claim scopes for anonymous-unclaimed, the full
granted set otherwise:
{
"access_token": "<token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api.read api.write"
}Never issue a refresh_token — the same identity_assertion is
re-exchanged to refresh until it expires. Revocation runs through
/oauth2/revoke (RFC 7009):
the agent POSTs token=<access_token>&token_type_hint=access_token, the
service marks it revoked (200, idempotent), and the identity_assertion
stays valid so the agent can mint a fresh access_token.
Rate limiting
The /agent/identity endpoint is unauthenticated for anonymous registration
and accepts bearer ID-JAGs for identity assertion. Both paths benefit
from two-tier rate limiting, checked in order:
- Per-IP limit (checked first). Prevents a single source from consuming the tenant's budget. Sensible default: 5/hour for anonymous, 60/hour for identity_assertion.
- Per-tenant limit (checked second). Global cap across IPs. Sensible default: 100/hour anonymous, 1000/hour identity_assertion.
Use a sliding-window counter backed by a shared store (Redis is common). Fail open on store errors to avoid blocking legitimate traffic. If no IP is available (e.g. stripped by a proxy), skip the per-IP check rather than rejecting.
Recommended audit events
Record the following state transitions for observability and incident response. How they're exposed — audit log, webhook, SIEM stream, admin API — is an implementation choice; the set of events and the data they carry is the useful baseline.
| Event | When | Recommended fields |
|---|---|---|
registration.created | Any successful /agent/identity POST | registration_id, registration_type |
assertion.issued | A service-signed identity_assertion is minted | registration_id |
token.issued | /oauth2/token returns an access_token | registration_id, scope |
token.revoked | /oauth2/revoke invalidates a credential | registration_id |
claim.requested | /agent/identity/claim called (or implicit on service_auth) | registration_id, email |
user_code.minted | user_code minted at ceremony start | registration_id |
claim.confirmed | The user completes the ceremony | registration_id, claimed_by_user_id |
registration.expired | Unclaimed registration past its TTL | registration_id |
registration.revoked | SET processed at /agent/event/notify | registration_id, iss, sub |
For ID-JAG flows, include iss, sub, agent_platform, and
agent_context_id so operators can correlate with provider-side logs.
Services that already expose resource events (API keys, invitations,
membership, or whatever principal the service creates) should consider
tagging those events with created_by_agent: true and a status field
(unclaimed / claimed / expired) so consumers don't have to
cross-reference the agent-registration events to determine whether a
given resource is agent-related.
Security considerations
- Token hashing. The
claim_token,claim_attempt_token, anduser_codeare all bearer secrets with no proof of possession — store only SHA-256 hashes. Plaintext leaves the server exactly once:claim_token+user_codein the ceremony response to the agent,claim_attempt_tokeninside theverification_uriquery string. - user_code entropy + TTL. Use a CSPRNG (
crypto.randomInt) for theuser_code. Default to a short TTL (≤10 min) and tight per-claim retry limits at the/claimform-action — 6-digit codes are guess-bounded only by lockout, not entropy. - IP logging. Capture IPs at registration, claim, and complete for audit trail.
- Scope on /claim. The page and its form-action are public but must resolve to a tenant / environment, and reject tokens that don't belong to that scope even if the hash somehow collides.
- Key reuse across the claim boundary. For anonymous, the in-place permission swap means anyone who captured the access_token pre-claim retains access post-claim with the new scopes. Offer forced rotation as an opt-in for security-sensitive tenants.
- Bulk revocation. Provide an operator-facing mechanism to revoke all outstanding agent credentials for a tenant in one shot — for incident response.
- Assertion replay. Cache
jtivalues for at least the assertion lifetime plus clock skew. A shared store is required if/agent/identityruns across multiple replicas. - Trust list discipline. Treat the trusted-providers list as security-critical configuration. Changes should be audited and rolled out with the same care as any auth config change.