auth.md
June 10, 2026 · View on GitHub
You are an agent. This service supports agentic registration: discover → register → (claim if needed) → exchange for an access_token → call API → handle revocation. Follow the steps in order; do not skip ahead.
Examples use placeholder hosts: https://api.service.example.com (the resource server hosting the API you want to call) and https://auth.service.example.com (the authorization server that handles registration). Real services substitute their own.
Step 1 — Discover
Discovery is two hops — you may have already done this.
The 401 response that pointed you here also carries a WWW-Authenticate header with the PRM URL:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.service.example.com/.well-known/oauth-protected-resource"
Pull the resource_metadata value from that header and fetch it (1a). If you don't have the 401 in hand, the conventional path on the resource server is /.well-known/oauth-protected-resource.
1a. Fetch the Protected Resource Metadata
GET /.well-known/oauth-protected-resource
Response shape:
{
"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"]
}
What each field tells you:
resource— the canonical URL of the API you're trying to call. Use this as theaudwhen minting an ID-JAG.resource_name/resource_logo_uri— display name and logo for the service. Surface these to the user when asking for consent.authorization_servers— base URLs of the OAuth Authorization Server(s) for this resource. Theagent_authblock lives on one of these (see 1b).scopes_supported— scopes the resource server understands. The access_token you receive at Step 5 will be scoped to some subset.bearer_methods_supported— how you'll send the access_token in Step 6 ("header"=Authorization: Bearer …).
1b. Fetch the Authorization Server metadata
GET <authorization_servers[0]>/.well-known/oauth-authorization-server
Response shape:
{
"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 outer fields restate the PRM. The top-level OAuth endpoints (issuer, token_endpoint, revocation_endpoint, grant_types_supported) are standard RFC 8414 / RFC 7009 / RFC 7523 fields. The agent_auth block is the profile-specific bootstrap surface — read it in full. Every field is relevant:
issuer— the canonical issuer URL of this authorization server. Validate theissclaim of any token the AS signs against this.token_endpoint— where you exchange a service-signed identity assertion for an access_token (Step 5).revocation_endpoint— where you POST to revoke an access_token (RFC 7009).grant_types_supported— lists the grant types accepted at/oauth2/token.urn:ietf:params:oauth:grant-type:jwt-bearer(RFC 7523) is for exchanging your identity_assertion for an access_token (Step 5).urn:workos:agent-auth:grant-type:claimis the polling grant for the claim ceremony (Step 4c) — a profile-specific URN so it doesn't collide with services that also implement standard RFC 8628 device authorization at the same endpoint.agent_auth.skill— the URL of this document.agent_auth.identity_endpoint— where you POST to register (Step 3).agent_auth.claim_endpoint— where you POST the claim invite for anonymous registrations (Step 4) and where the agent polls for ceremony completion at/view.agent_auth.events_endpoint— where the provider POSTs a Security Event Token (RFC 8417) per RFC 8935 push delivery to notify the service of upstream identity events. You don't call this; it tells you what to expect.agent_auth.identity_types_supported— which registration methods this service accepts. Pick yours from Step 2.agent_auth.identity_assertion.assertion_types_supported— which assertion types this service accepts under theidentity_assertionshape (currently ID-JAG).agent_auth.events_supported— event schemas this service can ingest (currently revocation). Informational; you don't act on these directly.
Step 2 — Pick a method
Use this decision tree:
- You have a session tied to a user identity and can exchange it for an ID-JAG, audience-bound to this service → identity_assertion + id-jag.
- You have only the user's email → service_auth. Claim ceremony required.
- You have neither → anonymous. Claim ceremony optional; deferred until the user wants to take ownership.
For identity_assertion, check that your assertion type is in agent_auth.identity_assertion.assertion_types_supported, if not listed then stop. For service_auth and anonymous, identity_types_supported is informational — send the body and fall back on the *_not_enabled error if the service opted out.
Step 3 — Register
Before sending an identity_assertion or service_auth body, surface the service's resource_name and resource_logo_uri (from Step 1a) and the scope set you'll be acting under, and confirm with the user. This is the user's only consent gate before their identity is asserted to the service. Skip this for anonymous — there is no user identity to assert.
identity_assertion + id-jag
Before minting the ID-JAG, confirm your provider is on this service's trust list (publishing format is service-specific — check the AS metadata or service docs). If it isn't, fall back to service_auth or anonymous.
Mint the assertion with:
aud= theresourcefrom the PRMiss= your provider's issuer URL (must be on the trust list above)email_verified: trueORphone_number_verified: true- Fresh
jti - Near-term
exp(~5 minutes) auth_time— epoch seconds when the user last authenticated at your provider. Required. The service rejects ID-JAGs whose underlying user authentication is older than itsidJagMaxAuthAgeSecondswindow.
POST /agent/identity
Content-Type: application/json
{
"type": "identity_assertion",
"assertion_type": "urn:ietf:params:oauth:token-type:id-jag",
"assertion": "<your ID-JAG JWT>"
}
The response has two shapes depending on whether the service already has a delegation on file for (iss, sub).
No confirmation needed — (iss, sub) is known, or the service JIT-provisioned a fresh user (no email/phone collision with existing accounts):
{
"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"]
}
Keep identity_assertion and go to Step 5.
Confirmation required (401) — (iss, sub) is unknown but the ID-JAG's verified email or phone matched an existing account at the service. The service won't silently bind the delegation — the user has to confirm linking the provider identity to their account.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: AgentAuth error="interaction_required", error_description="…"
{
"error": "interaction_required",
"error_description": "…",
"registration_id": "reg_...",
"registration_type": "identity_assertion",
"claim_url": "https://auth.service.example.com/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": "https://auth.service.example.com/login?return_to=%2Fclaim%3Fclaim_attempt_token%3D...",
"interval": 5
}
}
Same claim block as the service_auth registration response — the user signs in to the service, sees a confirmation page that names your provider ("Acme Provider is asking to link this account so the agent it runs can act on your behalf"), and types the user_code to confirm. Surface verification_uri + user_code to the user (see Step 4b) and poll the token endpoint (see Step 4c).
After the user confirms, the next presentation of an ID-JAG for the same (iss, sub, aud) is accepted directly — no confirmation needed.
Login required (401) — auth_time is missing or older than the service's max-age. The agent has to go back to its own provider, get the user to re-authenticate there (e.g., prompt=login on the provider's auth endpoint), and re-mint a fresh ID-JAG. This is distinct from the confirmation case above: nothing the user does at the service helps; the freshness has to be established upstream.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: AgentAuth error="login_required", max_age="3600", error_description="…"
{
"error": "login_required",
"error_description": "auth_time is …s old; max allowed is 3600s. Re-authenticate at the provider and request a fresh ID-JAG.",
"max_age": 3600
}
service_auth
POST /agent/identity
Content-Type: application/json
{
"type": "service_auth",
"login_hint": "user@example.com"
}
Response (200):
{
"registration_id": "reg_...",
"registration_type": "service_auth",
"claim_url": "https://auth.service.example.com/agent/identity/claim",
"claim_token": "clm_...",
"claim_token_expires": "2026-05-21T17:31:25.994Z",
"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
}
}
No identity_assertion yet — the claim ceremony is bundled into the registration response. Its shape borrows from RFC 8628 device-authorization (user_code, verification_uri, expires_in, interval) with a claim_attempt_token binding parameter embedded in verification_uri so the URL identifies the registration without leaking the user-typed user_code. The agent surfaces claim.verification_uri and claim.user_code to the user, and polls the token_endpoint from AS metadata with a profile-specific grant until the ceremony completes (see Step 4). claim_token is returned exactly once — hold it in memory for the duration of the ceremony; do not persist it past Step 4.
anonymous
POST /agent/identity
Content-Type: application/json
{ "type": "anonymous" }
Response (200):
{
"registration_id": "reg_...",
"registration_type": "anonymous",
"identity_assertion": "<service-signed JWT>",
"assertion_expires": "2026-05-04T13:00:00.000Z",
"pre_claim_scopes": ["api.read"],
"claim_url": "https://auth.service.example.com/agent/identity/claim",
"claim_token": "clm_...",
"claim_token_expires": "2026-05-21T17:26:32.915Z",
"post_claim_scopes": ["api.read", "api.write"]
}
The identity_assertion exchanges at /oauth2/token for an access_token with pre_claim_scopes immediately. If you also want a human to take ownership and unlock post_claim_scopes, go to Step 4. Otherwise skip to Step 5. claim_token is returned exactly once — hold it in memory for the duration of the ceremony; do not persist it past Step 4.
Step 4 — Claim ceremony
The end goal: get a signed-in user to confirm a 6-digit user_code you supply them. The code travels from you → user, not from the service → user; the user authenticates to the service and types the code into a page the service owns. This binds the agent's context to the user's authenticated session at the service. The ceremony fields (user_code, verification_uri, expires_in, interval) borrow their shape from RFC 8628 device authorization, and polling happens at the standard token_endpoint with a profile-specific grant.
4a. Get the ceremony materials
For service_auth registrations, you already have them — they're in the claim block of the Step 3 response. Skip to 4b.
For anonymous registrations, ask the service to start a ceremony:
POST /agent/identity/claim
Content-Type: application/json
{
"claim_token": "clm_...",
"email": "user@example.com"
}
Response (200):
{
"registration_id": "reg_...",
"claim_attempt_id": "cla_...",
"status": "initiated",
"expires_at": "2026-05-21T17:31:25.994Z",
"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
}
}
The claim_attempt block here — same shape as the claim block in the service_auth registration response — borrows from RFC 8628 device-authorization, with claim_attempt_token embedded in verification_uri so the URL identifies the registration without leaking the user-typed user_code. Surface verification_uri + user_code to the user; poll the standard token_endpoint from AS metadata with the claim grant (see 4c).
The email you supply on anonymous /claim binds the registration to the human you intend the agent to act on behalf of — only that signed-in user can complete the ceremony. Without this, a third party who intercepted the user_code could claim the agent for themselves.
4b. Hand off to the user
Surface verification_uri and user_code to the user in a single message. Suggested copy:
Open this link, sign in (or sign up), and enter this 6-digit code: 123456 https://auth.service.example.com/login?return_to=...
Be explicit that the code goes into the page they land on after signing in — not back to you. The user will:
- Open
verification_uri. - Authenticate with the service (existing user → sign in; new user → sign up and verify email, depending on the service).
- Land on the claim page, see "you're signed in as
", type the user_code, and submit.
4c. Poll for completion
Poll the standard token_endpoint (from AS metadata) with the profile-specific claim grant, passing your claim_token:
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:workos:agent-auth:grant-type:claim
&claim_token=<clm_...>
Why a profile-specific grant URN (and not urn:ietf:params:oauth:grant-type:device_code directly)? So this surface doesn't collide with a service that also implements standard RFC 8628 device authorization at the same token endpoint — the AS routes by grant_type, no risk of a claim_token being looked up in a device-auth store or vice versa.
Response while waiting (RFC 8628 §3.5 vocabulary):
{ "error": "authorization_pending", "error_description": "..." }
Response on success: a standard OAuth token response, plus an identity_assertion extension so you have a refresh path via the JWT-bearer grant once this access_token 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-21T18:31:25.994Z"
}
Use access_token immediately on /api/resource; cache identity_assertion for the eventual refresh.
For anonymous flows, completion of the ceremony revokes any pre-claim access_tokens you held. Use the post-claim access_token from this response; drop the pre-claim one. The pre-claim identity_assertion (v1) also gets superseded by the v2 returned here — the v2 carries the user's email/email_verified claims, the v1 didn't.
If the user_code window passes without the user finishing:
{ "error": "expired_token", "error_description": "..." }
Two cases distinguish what to do next:
- Registration is still active (most common — the user_code's 10-minute timer expired but the outer claim window is still open): call
POST /agent/identity/claimwith the sameclaim_tokenand the sameemailto mint a freshclaim_attemptblock. Surface the newuser_codeandverification_urito the user and resume polling. This works for both anonymous and service_auth registrations. - Registration itself has expired (the outer claim window — typically 24h — has closed): start over at Step 3.
If you can't tell which it is from context, try re-initiating first; the claim endpoint returns 410 claim_expired if the registration is gone, at which point you restart at Step 3.
Honor interval (in seconds); on slow_down back off. The next poll after expires_in will return expired_token.
Step 5 — Exchange the assertion
POST the identity_assertion to the AS metadata's token_endpoint with the RFC 7523 JWT-bearer grant. The resource parameter is optional but recommended — it pins the access_token to the API you're calling.
POST /oauth2/token
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/
Response (200):
{
"access_token": "<token>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api.read api.write"
}
Extract access_token and go to Step 6. The same identity_assertion can be re-used to mint additional access_tokens until it expires.
If /oauth2/token returns invalid_grant, your identity_assertion is expired or revoked. Restart the flow at Step 3 to mint a fresh identity assertion.
Step 6 — Use the access_token
Present the access_token as a bearer token:
GET /api/some-resource
Authorization: Bearer <access_token>
Refresh. When the access_token expires (expires_in seconds after issuance), re-call Step 5 with the same identity_assertion. When the identity assertion itself expires or /oauth2/token returns invalid_grant, restart at Step 3. There is no OAuth refresh_token in this flow — the two-step pattern replaces it.
If you get a 401 on a previously-working access_token: try Step 5 once with the current assertion. If that also fails, discard the expired identity assertion, and restart at Step 1.
Full API reference: https://docs.service.example.com/.
Errors
Errors at /agent/identity and /agent/identity/claim/* use profile-specific codes (the registration ceremonies have no OAuth analog). Errors at /oauth2/token use OAuth-standard vocabulary per RFC 6749 / RFC 7523.
| Code | Where | What to do |
|---|---|---|
anonymous_not_enabled | /agent/identity | This service doesn't accept anonymous. Pick another method from Step 2. |
service_auth_not_enabled | /agent/identity | service_auth (email-based) disabled here. Pick another method. |
issuer_not_enabled | /agent/identity | Provider not on this service's trust list. Pick another method. |
invalid_request | /agent/identity | Body shape, missing claims, ID-JAG signature/jti/aud problems, or unverified identity. Fix the input (mint a fresh ID-JAG if signature/jti/aud/exp-related). |
interaction_required (401) | /agent/identity (ID-JAG) | Step-up: ID-JAG matched an existing account but no (iss, sub) delegation yet. Body carries a claim block; surface it to the user (see Step 4). |
login_required (401) | /agent/identity (ID-JAG) | auth_time missing or older than max_age. Re-authenticate the user at your provider (prompt=login or equivalent) and mint a fresh ID-JAG. The service can't help here. |
invalid_claim_token | /agent/identity/claim | claim_token wrong or expired. Restart at Step 3. |
claimed_or_in_flight | /agent/identity/claim | Wrong endpoint for this registration kind, or already claimed. Re-read the Step 3 response and follow Step 4. |
claim_expired | /agent/identity/claim | The registration expired before the user finished. Restart at Step 3. |
invalid_grant | /oauth2/token | Assertion expired, revoked, replayed, or otherwise failed verification. Restart at Step 3 to mint a fresh one. |
invalid_client | /oauth2/token | client_id not recognized. Re-read AS metadata. |
unsupported_grant_type | /oauth2/token | grant_type is not one of the two supported values (urn:ietf:params:oauth:grant-type:jwt-bearer for token exchange, urn:workos:agent-auth:grant-type:claim for ceremony polling). |
authorization_pending | /oauth2/token (claim grant) | User hasn't completed the ceremony yet. Honor interval from the claim or claim_attempt block; retry. |
expired_token | /oauth2/token (claim grant) | user_code window or outer claim window has closed. Re-call /agent/identity/claim to mint a fresh user_code; if that returns claim_expired, restart at Step 3. |
slow_down | /oauth2/token (claim grant) | Polling too fast. Add at least 5s to your interval and retry. |
rate_limited (429) | any | Back off and retry. |
user_code errors are surfaced to the user on the claim page — they never reach you. Your poll keeps returning "status": "pending" until either the user gets the code right or the window expires ("status": "expired").
Retry policy:
- 5xx → exponential backoff, retry the same request.
- 4xx → do not retry the same payload; act on the table above.
- 401 on a previously-working access_token → retry Step 5 once with the current assertion. If that fails, restart at Step 1.
Revocation
Two independent layers can kill what you're holding:
- Credential layer (RFC 7009,
revocation_endpoint) — agent-callable. POSTtoken=<access_token>&token_type_hint=access_token(form-encoded) to the top-levelrevocation_endpointto kill one access_token. 200 on success, idempotent. Youridentity_assertionis intact; re-run Step 5 to mint a fresh access_token. - Registration layer (RFC 8935 Security Event Token delivery,
agent_auth.events_endpoint) — provider-driven. The provider that minted your ID-JAG can POST a SET (RFC 8417) (Content-Type: application/secevent+jwt) to this service'sevents_endpoint. The service invalidates the identity assertion and every access_token derived from it. You don't call this; you discover it the next time/oauth2/tokenreturnsinvalid_grant— restart at Step 3.
On a 401 for a previously-working access_token: try Step 5 once. If /oauth2/token succeeds, the credential was revoked at the credential layer and your fresh access_token works. If /oauth2/token returns invalid_grant, the registration was killed at the registration layer — restart at Step 3.