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

Discovery flow diagram showing agent fetching PRM then AS metadata

2. Pick a Method

Use this decision tree:

  1. You have a session tied to a user identity and can exchange it for an ID-JAG, audience-bound to this serviceidentity_assertion + ID-JAG
  2. You have only the user's emailservice_auth. Claim ceremony required.
  3. You have neitheranonymous. 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
}
identity_assertion flow: agent presents ID-JAG, service validates, returns identity_assertion for exchange

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

service_auth flow: agent provides email, service returns claim ceremony materials, user completes in browser

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.

Anonymous flow: agent registers without identity, gets limited access, optionally claims later

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:

Open this link, sign in (or sign up), and enter this 6-digit code: 123456
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:

  1. Try Step 7 once with the current assertion.
  2. If that also fails, discard the assertion and restart at Step 1.

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.

CodeWhereWhat to do
anonymous_not_enabled/agent/identityService doesn't accept anonymous. Pick another method.
service_auth_not_enabled/agent/identityservice_auth disabled here. Pick another method.
issuer_not_enabled/agent/identityProvider not on this service's trust list. Pick another method.
invalid_request/agent/identityBody shape, missing claims, ID-JAG signature/jti/aud problems. Fix the input.
interaction_required (401)/agent/identityID-JAG matched existing account but no delegation. Surface claim block to user.
login_required (401)/agent/identityauth_time missing or older than max_age. Re-authenticate user at provider.
invalid_claim_token/agent/identity/claimclaim_token wrong or expired. Restart at Step 3.
claimed_or_in_flight/agent/identity/claimAlready claimed or wrong endpoint. Re-read Step 3 response.
claim_expired/agent/identity/claimRegistration expired before user finished. Restart at Step 3.
invalid_grant/oauth2/tokenAssertion expired, revoked, or replayed. Restart at Step 3.
invalid_client/oauth2/tokenclient_id not recognized. Re-read AS metadata.
unsupported_grant_type/oauth2/tokengrant_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)anyBack 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/token returns invalid_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.