Integrate

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/identity endpoint.
  • 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

  1. 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_assertion for the matched user synchronously.
  2. 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_assertion immediately. 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 a login_hint. No assertion is issued until the user completes the claim ceremony. Use this when pre-claim usage is unacceptable.

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

  1. Publish .well-known/oauth-protected-resource (resource + authorization_servers) and .well-known/oauth-authorization-server (top-level OAuth endpoints + agent_auth block).
  2. Return WWW-Authenticate: Bearer resource_metadata="..." on 401 responses.
  3. Host a /agent/identity endpoint (and its /claim sub-endpoint) that dispatches on type and returns a service-signed identity_assertion.
  4. 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.
  5. Host /oauth2/revoke (RFC 7009) for agent-initiated credential revocation.
  6. Accept provider-initiated Security Event Tokens (RFC 8417) at the advertised events_endpoint.
  7. For agent verified: maintain a trust list of agent providers and verify ID-JAG signatures against the provider's JWKS.
  8. For user claimed: implement the claim ceremony at /agent/identity/claim and a service-owned page where the signed-in user types the user_code.
  9. 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

json
{
  "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

json
{
  "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
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:

  1. 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.
  2. 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.
  3. 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.


Flow path

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

Agent verified flow
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.

http
POST /agent/identity HTTP/1.1
Host: auth.service.example.com
Content-Type: application/json
json
{
  "type": "identity_assertion",
  "assertion_type": "urn:ietf:params:oauth:token-type:id-jag",
  "assertion": "eyJhbGc..."
}

Implementation steps

  1. Decode the ID-JAG header to obtain kid and alg.
  2. Look up the issuer (iss) in your trusted providers list. Reject if unknown.
  3. Fetch JWKS from the provider (see Verifying ID-JAGs for caching).
  4. Verify the signature using the key matching kid.
  5. Validate claims: aud matches your auth server; exp is future; iat is not unreasonably future; jti has not been seen recently; client_id resolves to a known provider identity; at least one of email_verified or phone_number_verified is true; auth_time is present and within your max-age window (see auth_time freshness).
  6. 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.
  7. Mint a service-signed identity_assertion (typed oauth-id-jag+jwt, signed by your AS key, sub = the registration ID). This is what the agent exchanges at /oauth2/token.

Clean-match response:

json
{
  "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.

json
{ "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:

http
WWW-Authenticate: AgentAuth error="login_required", max_age="3600", error_description="..."
json
{ "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.

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:

http
WWW-Authenticate: AgentAuth error="interaction_required", error_description="..."
json
{
  "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: true for victim@example.com and 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:

http
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

  1. Verify the SET signature against the issuer's JWKS (same trust path as ID-JAG verification).
  2. Validate iss against the trust list, aud against your service, and enforce jti uniqueness for replay protection.
  3. Dispatch on each entry in the events claim — for the identity-assertion-revoked schema, find all credentials issued for (iss, sub, aud) and invalidate them. Unknown schemas can be ignored (RFC 8417 §2.2).
  4. 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.


Flow path

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

User claimed · 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
end

Email (service_auth)

User claimed · 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
end

POST /agent/identity handlers

Same base endpoint as agent verified — dispatch on type. The user claimed flow has two request shapes.

http
POST /agent/identity HTTP/1.1
Host: auth.service.example.com
Content-Type: application/json

Anonymous 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.

json
{ "type": "anonymous" }
Implementation steps
  1. Apply rate limits (see Rate limiting).
  2. 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.
  3. Generate a claim token (prefixed, high-entropy — e.g. clm_ + 25 chars base62). Store only its SHA-256 hash. Return the plaintext exactly once.
  4. Mint a service-signed identity_assertion bound to the registration. At /oauth2/token exchange time, unclaimed anonymous registrations get the pre-claim scope set.
  5. Schedule an expiration job at the registration's TTL to mark the claim expired.
json
{
  "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.

json
{
  "type": "service_auth",
  "login_hint": "user@example.com"
}
Implementation steps
  1. Create a registration row marked as service_auth and persist the asserted email as claim_email.
  2. Generate a claim_token (returned to the agent), a claim_attempt_token (embedded in verification_uri), and a 6-digit user_code. Store SHA-256 hashes of all three; return the plaintext claim_token and user_code, embed the claim_attempt_token in verification_uri.
  3. Return the claim handles + a claim block — but no identity_assertion. The assertion is minted when the user completes the ceremony and the agent polls /oauth2/token with the claim grant.
json
{
  "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:

EntrypointCeremony block returned atClaim 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.

json
{
  "claim_token": "clm_abc123...",
  "email": "user@example.com"
}
json
{
  "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_token and look up the registration. Reject if not found (invalid_claim_token), already claimed (claimed_or_in_flight), or expired (claim_expired).
  • Record claim_email on the registration so the claim page can enforce the binding. The email binds 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 the user_code could claim the agent on their own account.
  • Mint a claim_attempt_token and a user_code; store SHA-256 hashes of both, return the plaintexts. A fresh claim_attempt_id is minted on each attempt (including same-email retries) and the previous URL stops working.
  • The verification_uri should 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:

  1. Resolves the registration via claim_attempt_token (from the URL).
  2. Verifies the signed-in user matches registration.claim_email if set — rejects mismatches.
  3. Renders a form that POSTs claim_attempt_token + the typed user_code to a service-owned form-action endpoint.
  4. On the form post, verifies the user_code against 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:

http
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:

json
{ "error": "authorization_pending", "error_description": "..." }

On completion, a standard OAuth token response extended with identity_assertion and assertion_expires:

json
{
  "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 or status === "expired"expired_token. Not yet claimedauthorization_pending. Claimed → mint a fresh access_token and a fresh identity_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_assertion carries the now-known email / email_verified claims; the v1 the agent held has neither.
  • For service_auth, this is the first identity_assertion for 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 the user_code or outer claim window closes, return expired_token; the agent re-calls /agent/identity/claim for 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.

http
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:

json
{
  "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:

  1. 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.
  2. 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.

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.

EventWhenRecommended fields
registration.createdAny successful /agent/identity POSTregistration_id, registration_type
assertion.issuedA service-signed identity_assertion is mintedregistration_id
token.issued/oauth2/token returns an access_tokenregistration_id, scope
token.revoked/oauth2/revoke invalidates a credentialregistration_id
claim.requested/agent/identity/claim called (or implicit on service_auth)registration_id, email
user_code.minteduser_code minted at ceremony startregistration_id
claim.confirmedThe user completes the ceremonyregistration_id, claimed_by_user_id
registration.expiredUnclaimed registration past its TTLregistration_id
registration.revokedSET processed at /agent/event/notifyregistration_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, and user_code are all bearer secrets with no proof of possession — store only SHA-256 hashes. Plaintext leaves the server exactly once: claim_token + user_code in the ceremony response to the agent, claim_attempt_token inside the verification_uri query string.
  • user_code entropy + TTL. Use a CSPRNG (crypto.randomInt) for the user_code. Default to a short TTL (≤10 min) and tight per-claim retry limits at the /claim form-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 jti values for at least the assertion lifetime plus clock skew. A shared store is required if /agent/identity runs 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.