auth.md Protocol Specification — June 2026
Source: github.com/workos/auth.md
1. Discovery
Discovery is two hops. A 401 response carries a WWW-Authenticate header pointing to the Protected Resource Metadata (PRM):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.service.example.com/.well-known/oauth-protected-resource"1a. Protected Resource Metadata
Fetch /.well-known/oauth-protected-resource on the resource server (RFC 9728):
GET /.well-known/oauth-protected-resource
{
"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"]
}Key fields: resource — canonical API URL (use as aud when minting an ID-JAG); resource_name / resource_logo_uri — display to the user for consent; authorization_servers — where the agent_auth block lives.
1b. Authorization Server Metadata
Fetch /.well-known/oauth-authorization-server on the AS (RFC 8414):
GET /.well-known/oauth-authorization-server
{
"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 agent_auth block contains: identity_endpoint (where you POST to register), claim_endpoint (claim ceremony initiation), events_endpoint (SET delivery for revocation), identity_types_supported (which methods this service accepts), and identity_assertion.assertion_types_supported (accepted assertion types under the identity_assertion shape).
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 ownership.
For identity_assertion, check that your assertion type is in agent_auth.identity_assertion.assertion_types_supported. 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.
3. Register — identity_assertion + ID-JAG
Before minting the ID-JAG, confirm your provider is on the service's trust list. Mint with: aud = PRM resource, iss = your provider's issuer, email_verified: true or phone_number_verified: true, fresh jti, near-term exp (~5 min), and auth_time (epoch seconds of last user authentication).
Before sending, surface the service's resource_name and resource_logo_uri plus the scope set to the user for consent.
Request
POST /agent/identity
Content-Type: application/json
{
"type": "identity_assertion",
"assertion_type": "urn:ietf:params:oauth:token-type:id-jag",
"assertion": ""
} Success — no confirmation needed
The (iss, sub) pair is known or JIT-provisioned:
// 200 — No confirmation needed
{
"registration_id": "reg_...",
"registration_type": "identity_assertion",
"identity_assertion": "",
"assertion_expires": "2026-05-04T13:00:00.000Z",
"scopes": ["api.read", "api.write"]
} Keep identity_assertion and proceed to Exchange the Assertion.
401 — interaction_required
The ID-JAG's verified email/phone matched an existing account but no (iss, sub) delegation exists. The user must confirm linking:
// 401 — interaction_required (email/phone matched existing 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=...",
"interval": 5
}
}Surface verification_uri + user_code to the user and poll (Claim Ceremony).
401 — login_required
auth_time is missing or older than the service's max-age. Re-authenticate the user at your provider (prompt=login) and re-mint a fresh ID-JAG:
// 401 — login_required (auth_time too old)
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
}4. Register — service_auth
When you only have the user's email. The response includes a claim block — the claim ceremony is mandatory.
Request
POST /agent/identity
Content-Type: application/json
{
"type": "service_auth",
"login_hint": "[email protected]"
}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=...",
"interval": 5
}
}No identity_assertion yet — you must complete the Claim Ceremony first. The claim block borrows shape from RFC 8628 (user_code, verification_uri, expires_in, interval).
5. Register — anonymous
No user identity required. Returns an identity_assertion usable immediately with pre_claim_scopes. Optionally, the user can later take ownership via the Claim Ceremony to unlock post_claim_scopes.
Request
POST /agent/identity
Content-Type: application/json
{
"type": "anonymous"
}Response
// 200
{
"registration_id": "reg_...",
"registration_type": "anonymous",
"identity_assertion": "",
"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. claim_token is returned exactly once — hold it in memory for the claim ceremony; do not persist it.
6. 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; the user authenticates to the service and types the code into a service-owned page. This follows RFC 8628 device-authorization shape.
6a. Get Ceremony Materials
For service_auth: already in the claim block of the registration response. For anonymous: POST to the claim endpoint:
POST /agent/identity/claim
Content-Type: application/json
{
"claim_token": "clm_...",
"email": "[email protected]"
}// 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=...",
"interval": 5
}
}6b. Hand Off to the User
Surface verification_uri and user_code in a single message:
https://auth.service.example.com/login?return_to=...The user opens the link, authenticates with the service, lands on the claim page, and types the user_code.
6c. Poll for Completion
Poll the token_endpoint with the profile-specific claim grant:
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:workos:agent-auth:grant-type:claim
&claim_token=clm_...While waiting:
// While user hasn't completed
{
"error": "authorization_pending",
"error_description": "..."
}On success:
// On success
{
"access_token": "",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api.read api.write",
"identity_assertion": "",
"assertion_expires": "2026-05-21T18:31:25.994Z"
} For anonymous flows, completion revokes any pre-claim access_tokens. Use the post-claim access_token; drop the pre-claim one. If user_code expires, re-call POST /agent/identity/claim with the same claim_token + email for a fresh code. If the registration itself expired, restart at Step 3.
7. Exchange the Assertion
POST the identity_assertion to the token_endpoint with the RFC 7523 JWT-bearer grant:
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=
&resource=https://api.service.example.com/ // 200
{
"access_token": "",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api.read api.write"
} The same identity_assertion can be reused to mint additional access_tokens until it expires. If /oauth2/token returns invalid_grant, the assertion is expired or revoked — restart at Step 3.
8. Use the access_token
Present the token as a Bearer header:
GET /api/some-resource
Authorization: Bearer Refresh Pattern
When access_token expires, re-call Step 7 with the same identity_assertion. When the assertion itself expires or /oauth2/token returns invalid_grant, restart at Step 3. There is no OAuth refresh_token — the two-step pattern replaces it.
401 Recovery
On a 401 for a previously-working access_token:
9. Errors
Errors at /agent/identity and /agent/identity/claim use profile-specific codes. Errors at /oauth2/token use OAuth-standard vocabulary per RFC 6749 / RFC 7523.
| Code | Where | What to do |
|---|---|---|
anonymous_not_enabled | /agent/identity | Service doesn't accept anonymous. Pick another method. |
service_auth_not_enabled | /agent/identity | service_auth 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. Fix the input. |
interaction_required (401) | /agent/identity | ID-JAG matched existing account but no delegation. Surface claim block to user. |
login_required (401) | /agent/identity | auth_time missing or older than max_age. Re-authenticate user at provider. |
invalid_claim_token | /agent/identity/claim | claim_token wrong or expired. Restart at Step 3. |
claimed_or_in_flight | /agent/identity/claim | Already claimed or wrong endpoint. Re-read Step 3 response. |
claim_expired | /agent/identity/claim | Registration expired before user finished. Restart at Step 3. |
invalid_grant | /oauth2/token | Assertion expired, revoked, or replayed. Restart at Step 3. |
invalid_client | /oauth2/token | client_id not recognized. Re-read AS metadata. |
unsupported_grant_type | /oauth2/token | grant_type not one of the two supported values. |
authorization_pending | /oauth2/token (claim) | User hasn't completed ceremony. Honor interval; retry. |
expired_token | /oauth2/token (claim) | user_code window closed. Re-call /agent/identity/claim for fresh code; if claim_expired, restart Step 3. |
slow_down | /oauth2/token (claim) | Polling too fast. Add 5s to interval and retry. |
rate_limited (429) | any | Back off and retry. |
Retry Policy
- 5xx → exponential backoff, retry the same request.
- 4xx → do not retry the same payload; act on the error table above.
- 401 on a previously-working access_token → retry Step 7 once. If that fails, restart at Step 1.
10. Revocation
Two independent layers can invalidate what you're holding:
Credential Layer — RFC 7009
Agent-callable. POST to the top-level revocation_endpoint to kill one access_token. 200 on success, idempotent. Your identity_assertion is intact; re-run Step 7 for a fresh access_token.
POST /oauth2/revoke
Content-Type: application/x-www-form-urlencoded
token=&token_type_hint=access_token Registration Layer — RFC 8935 SET Delivery
Provider-driven. The provider that minted your ID-JAG can POST a Security Event Token (RFC 8417) to this service's events_endpoint. The service invalidates the identity assertion and every access_token derived from it. You discover this the next time /oauth2/token returns invalid_grant — restart at Step 3.
Recovery
On a 401 for a previously-working access_token:
- Try Step 7 once. If it succeeds → credential layer revocation; fresh access_token works.
- If
/oauth2/tokenreturnsinvalid_grant→ registration layer killed; restart at Step 3.
11. Security
Token Hashing
Access tokens and claim tokens SHOULD be stored as SHA-256 hashes server-side. The raw token is returned to the agent exactly once; the server retains only the hash for later comparison.
OTP / user_code Entropy
The 6-digit user_code MUST be generated with cryptographically secure randomness (minimum 20 bits of entropy). Combined with the 10-minute expiry and rate limiting on verification attempts, brute-force is infeasible.
Replay Protection
Each ID-JAG carries a unique jti. The service MUST reject any ID-JAG whose jti has been seen before (within the exp window). This prevents replay of intercepted assertions.
CIMD — Claim Interception Mitigation by Design
The email field on anonymous /agent/identity/claim binds the registration to the intended human — 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. The claim_attempt_token embedded in verification_uri ensures the URL identifies the registration without leaking the user_code.
12. Rate Limiting
All endpoints enforce rate limits. Limits are applied in two dimensions:
- Per-IP — protects against credential stuffing and brute-force from a single source.
- Per-tenant — protects against one agent consuming disproportionate service resources.
When rate limited, the response uses standard 429 with Retry-After header:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"error": "rate_limited",
"error_description": "Too many requests. Retry after 30 seconds."
}On slow_down during claim polling, add at least 5 seconds to your interval and retry. On rate_limited (429) from any endpoint, back off and retry after the Retry-After value.