How an MCP client should tell your OAuth server who it is
Dynamic Client Registration is deprecated, and Client ID Metadata Documents answer only half the question. Here is what each pattern actually proves, and how auth.md adds the user delegation they leave out.
An agent hits your API, gets a 401, and needs to introduce itself to your authorization server. It has no client ID you issued, no secret, and nobody sitting at a consent screen. Three patterns now compete to answer that first request, and all three have shipping implementations behind them.
The MCP 2026-07-28 spec formally deprecated Dynamic Client Registration in favor of Client ID Metadata Documents, keeping DCR alive only for backward compatibility until a future version removes it. On August 6, Clerk shipped Client ID Metadata Documents as a beta, where a compatible client uses an HTTPS URL as its client_id. And auth.md, the open agent-registration protocol we published in May, went live inside AuthKit as Agent Registration on August 4.
So if you run an authorization server that agents are already knocking on, you have a decision to make this quarter. Here is how the three patterns compare on the same job, and why only one of them answers the question your on-call engineer will actually be asked.
The job, stated precisely
All three patterns give a client a stable, verifiable identity without a pre-issued secret. They differ in what "identity" means.
Client identity answers which piece of software is this. User delegation answers on whose authority is it acting, and what may it do. DCR and CIMD only do the first. auth.md builds on the first to do the second, which is why comparing all three as if they were interchangeable leads teams to ship half an answer.

MCP doesn't force one mechanism. The spec's registration priority order is pre-registration, then CIMD, then DCR, then prompting the user to enter client details by hand. A client walks down that list. Your server decides how far down it is willing to go.
Dynamic client registration
DCR is the original answer, standardized in RFC 7591. The client POSTs its metadata to a registration endpoint at runtime and the server issues a client_id in response.
It works, and inside a closed enterprise deployment it is often still the right call. DCR gives you a server-side registry of every client that ever connected, which is what you want when admins must approve each client or when compliance wants an audit trail. It is also the fallback for a client that cannot host stable web metadata.
At open-internet scale it degrades in four ways: client sprawl, since your server sees an unbounded number of auto-registered clients; a public write endpoint that becomes an abuse target; per-server credential lifecycle work made worse by clients that "recover" from errors by re-registering; and enterprise friction from anything self-registering against internal auth. RFC 7591 defines the registration request and response but requires no lifecycle policy: no mandatory expiration, no cleanup. RFC 7592 adds a management API, but nothing obliges you to implement it, so you cannot assume a cleanup story exists.
The 2026-07-28 spec kept one DCR fix even while deprecating the mechanism. Clients now must specify application_type at registration, because omitting it defaults to "web" under OIDC and gets localhost redirects from desktop and CLI apps rejected. Native apps, including CLI tools and locally hosted web apps reached over localhost, should send application_type: "native".
The same spec revision added a constraint that matters more than it first appears: clients must key persisted client credentials by the issuing authorization server, and must re-register when the authorization server changes. DCR credentials are not portable. CIMD client IDs are, because they are self-hosted HTTPS URLs any authorization server can resolve on demand. That is the structural argument for CIMD, and it is easy to miss.
Deprecation here is not removal. The spec's feature lifecycle policy carries a twelve-month minimum window between deprecation and the earliest possible removal, with a documented migration path required. WorkOS Connect continues to support DCR for clients that have not implemented CIMD yet. If your traffic is IDE plugins and CLIs, you will be serving DCR for a while.
Client ID metadata documents
CIMD replaces the registration write with a read. The client hosts a JSON document at an HTTPS URL it controls and uses that URL as its client_id:
The client_id must use the https scheme and must contain a path component, so https://ai.example.com on its own is not a valid client ID. The document must carry at least client_id, client_name, and redirect_uris.
The authorization request carries that URL, URL-encoded, in place of an opaque identifier:
Your server then fetches and validates: the body must be valid JSON containing the required fields, the document's own client_id must exactly match the URL used, the requested redirect_uri must appear in the document's redirect_uris, and the result should be cached per the response's HTTP cache headers.
That third check is the anti-impersonation guardrail. A phishing client can point at a real app's metadata URL, but the redirect URI it wants is not in the real app's document, so no code is ever issued to it.
Confidential clients get a bonus. A client that declares token_endpoint_auth_method: "private_key_jwt" alongside a jwks_uri gets strong client authentication with no per-server secret to store or rotate. That is a different document from the public-client example above, and the two auth methods should not appear in the same one.
Advertise support by publishing client_id_metadata_document_supported: true in your OAuth Authorization Server metadata. Clients check for it before trying CIMD.
The costs are real and specific. You are now fetching URLs supplied by strangers, so block loopback and private IP ranges, allow HTTPS only, set strict timeouts, cap the response body at a few kilobytes, and refuse redirects into private networks. A slow or unreachable metadata host stalls the authorize request itself, so treat fetch health as a first-class operational signal. Clerk surfaces each saved client's metadata fetch health next to its admission status. Cache per the response's own headers, and fail closed on a timeout.
And domain control is not reputation. CIMD proves the client owns the URL, nothing more. Unknown-domain warnings, publisher allowlists, and admin approval for sensitive scopes remain your job.
That last point is exactly where the current product work is landing. Clerk's beta adds a CIMD Clients tab where you allow a client by its Client ID URL and pick its scopes, allow popular clients with suggested scopes, review admission status and fetch health, and block clients that were implicitly allowed during an earlier connection. An "Advertise CIMD support" switch publishes CIMD in your authorization server metadata and records unknown clients on first connection for later review, and a companion "Only allow pre-registered clients to connect" switch restricts flows to clients you reviewed in advance. It is beta, enabled per workspace by contacting their support.
WorkOS Connect has read URL-style client_ids, resolved the metadata, and rendered the matching consent screen since November 30, 2025, and AuthKit's MCP docs cover enabling CIMD alongside DCR, PKCE, Resource Indicators, and Protected Resource Metadata.
What client identity still doesn't tell you
Suppose CIMD works perfectly. Your server now knows the request comes from software published at https://ai.example.com. It still does not know which human that agent is acting for, whether that human has an account with you, what the agent may do before anyone vouches for it, or how you revoke it later.
Client identity is a prerequisite for those answers, not a substitute. The plumbing question, who is this client, is solved. The product question, what does this client get, and on whose behalf, is still hand-rolled per service.
auth.md, the layer above
auth.md is an open protocol: a Markdown file published at your domain plus a small set of HTTP endpoints agents use to register on behalf of users. It composes standards you already have, RFC 9728 Protected Resource Metadata for discovery and ID-JAG identity assertions for delegation, and it needs no WorkOS account to publish or read. The reference implementation is MIT licensed on GitHub.
The Markdown file is the part written for agents. It names your service, lists which registration types you support, links to the PRM and the /agent/identity endpoint, inventories your scopes one line each, and points at pricing, terms, and privacy plus a contact channel for integration issues. Agents that find you through documentation, an SDK, or a search result can read it directly without probing for a 401 first. The structured metadata stays the machine-readable source of truth; auth.md is the prose summary that points back at it.
Discovery
Discovery is the same chain a spec-compliant MCP client already walks, and it is two hops. Your 401 points at the resource metadata:
The PRM at /.well-known/oauth-protected-resource advertises the resource and points at the authorization server:
The authorization server metadata at /.well-known/oauth-authorization-server is where the profile extension lives. Standard RFC 8414 fields sit at the top level, and the agent_auth block carries the agent-specific surface:
Three identity types map to two conceptual flows. identity_assertion carrying an ID-JAG 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.
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. That indirection is the reason the protocol can express per-step policy, and it is what neither DCR nor CIMD gives you.
The minimum implementation
Nine pieces, most of which are endpoints you already have:
- Publish
.well-known/oauth-protected-resourceand.well-known/oauth-authorization-server. - Return
WWW-Authenticate: Bearer resource_metadata="..."on 401 responses. - Host
/agent/identityand its/claimsub-endpoint, dispatching ontypeand returning a service-signedidentity_assertion. - Host
/oauth2/tokenfor the RFC 7523 JWT-bearer grant, plus the claim-grant poll. - Host
/oauth2/revokeper 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 provider JWKS.
- For user claimed, implement the ceremony at
/agent/identity/claimand a service-owned page where the signed-in user types the code. - Record audit events for every state change.
Agent verified: the ID-JAG path
A trusted agent provider, meaning a platform whose agents act on behalf of identifiable users, signs an assertion saying this agent is acting for this user. The header carries the ID-JAG content type marker and the payload carries audience-scoped claims:
The agent POSTs it to your identity endpoint:
Your handler decodes the header for kid and alg, looks up iss in your trust list and rejects unknown issuers, fetches the provider JWKS, verifies the signature, then validates claims: aud matches your authorization server, exp is in the 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, and auth_time is present and inside your max-age window. Then you match or provision the user and mint a service-signed assertion typed oauth-id-jag+jwt with sub set to the registration ID.
If your service already JIT-provisions users from an identity provider, this is a small change. It is standard JWT verification against a provider JWKS plus a delegation record per (iss, sub, aud), with no user-model changes.
The verification hygiene that matters. Cache JWKS per the response's Cache-Control with a floor around ten minutes and a ceiling around twenty-four hours, and refetch once on a kid cache miss before rejecting, so provider key rotation degrades gracefully. Cache seen jti values for at least exp - iat plus clock skew, which for a five-minute assertion means roughly six minutes, and use a shared store if /agent/identity runs across replicas. Accept iat one to two minutes in the future to absorb clock drift.
The trust list is the security boundary. A minimum entry is an issuer URL. Richer entries pin a service-controlled display_name, a JWKS URI, a CIMD URL, or an attestation policy such as requiring mfa in amr. Never render a client_name the provider sets directly, because a malicious provider would pick its own marketing copy for your consent screen. Treat the list as security-critical configuration, since compromising a trusted provider compromises every delegation routed through them.
CIMD does real work here, one layer down. When a provider's client_id is a URL rather than an opaque identifier, the service resolves it as a Client ID Metadata Document and verifies that its jwks_uri matches the key that verified the signature. That decouples the provider's identity from its signing keys, so rotation does not churn every consumer's trust list. This is CIMD doing the job it is genuinely good at, inside a protocol that answers the delegation question CIMD cannot.
The interesting parts are the refusals
Two checks are where the protocol earns its keep.
Stale authentication. An ID-JAG with a missing auth_time, or one older than your configured max age (default one hour), gets a 401:
The agent's recourse is to refresh the user's authentication at its own provider and mint a fresh assertion. Nothing the user does at your service helps. Apply the check universally, including to (iss, sub) pairs you already hold a delegation for, which is what stops indefinite session piggy-backing.
First-link step-up. A verified email that matches an existing account with no delegation on file does not bind silently. It returns 401 interaction_required with a claim block:
Without this gate, any trusted provider could mint an assertion 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, so their authenticated session is what authorizes the link.
That has a second benefit worth stating plainly: the ceremony is where your existing sign-in policies apply. Enterprise SSO, MFA, bot detection, terms re-acceptance. If acme.com is SSO-managed in your tenant, an assertion for alice@acme.com lands the user on a sign-in surface that refuses to complete until Alice authenticates through Acme's IdP. From the agent's perspective the flow is identical whether the gate is nothing, MFA, or full enterprise SSO. That is how ID-JAGs avoid becoming a bypass around your domain-bound policies.
The full resolution order for user matching is: existing (iss, sub) delegation wins, verified email or phone match triggers step-up, no match falls through to JIT provisioning or refusal per your policy. Reject assertions carrying neither a verified email nor a verified phone, because there is no basis for matching.
User claimed: anonymous start
An agent with nothing at all registers instantly:
pre_claim_scopes and post_claim_scopes in one response are the per-step policy that neither DCR nor CIMD can express. The agent can work immediately at read-only scopes and upgrade later, without you having to choose between locking it out and trusting it fully.
Generate the claim token as a prefixed high-entropy string, clm_ plus roughly twenty-five base62 characters, store only its SHA-256 hash, and return the plaintext exactly once. Flag the registration as agent-created so downstream events and admin UI can distinguish it, and schedule an expiration job at the registration's TTL.
User claimed: service_auth
When the agent knows the user's email but has no provider assertion, service_auth withholds the assertion entirely until a human vouches:
The response carries claim handles and a ceremony block, and deliberately no identity_assertion:
Use this entrypoint when pre-claim usage is unacceptable. Use anonymous start when it isn't and you'd rather let the agent get useful immediately.
The claim ceremony
Both entrypoints funnel into the same ceremony, and it inverts the direction people expect. Your service mints a six-digit user_code. The agent surfaces it to the user along with a verification_uri. The user signs in on a page you own and types the code there. The agent polls for completion. Your service never emails a code.

The fields borrow RFC 8628 device-authorization vocabulary: user_code, verification_uri, expires_in, interval. The verification_uri routes through your /login first, so the user authenticates before the claim page can identify them, and the embedded claim_attempt_token binds the URL to a specific registration without revealing the code.
For anonymous registrations the agent starts an attempt explicitly:
That email is load-bearing. It binds the registration to the human the agent acts for, so only that signed-in user can complete the ceremony. Without it, anyone who intercepts the user_code could claim the agent onto their own account. A fresh claim_attempt_id is minted on every attempt, including same-email retries, and the previous URL stops working.
The claim page resolves the registration from claim_attempt_token, verifies the signed-in user matches the recorded claim_email, renders the form, and re-checks the account on submit. It is a service-owned surface that agents never see.
Meanwhile the agent polls the standard token endpoint with a profile-specific grant:
It gets authorization_pending until the user finishes, slow_down if it polls faster than the advertised interval, and expired_token once the window closes. On completion it gets a standard OAuth token response extended with the assertion:
Polling could in principle reuse urn:ietf:params:oauth:grant-type:device_code, but then a service implementing standard RFC 8628 device authorization at the same token endpoint would have to disambiguate by inspecting the bearer value. A profile-specific URN routes by grant_type, which is where OAuth implementations already dispatch.
Completion is a real state transition, not a scope bump. For anonymous registrations, the pre-claim access tokens from earlier JWT-bearer exchanges are revoked, because the canonical credential is the one returned by the claim grant. The v2 assertion carries the now-known email and email_verified claims that the v1 assertion had no way to include. For service_auth, this is the first assertion the registration has ever had.

Credential exchange
Everything terminates at the same RFC 7523 JWT-bearer grant:
Verify the assertion against your own signing key: typ is oauth-id-jag+jwt, iss and aud equal your authorization server, exp is valid, and sub resolves to a registration you hold. Then issue an access token scoped to the registration's current state, which means pre-claim scopes for an unclaimed anonymous registration and the full granted set otherwise.
The token endpoint never issues a refresh token. The same assertion is re-exchanged to mint a fresh access token until the assertion itself expires, at which point the agent re-registers. One fewer long-lived bearer secret in circulation, and the registration record stays the single point of control.
Revocation has two independent layers
Agents lose credentials in two different ways, so there are two surfaces.
The agent or an admin kills one credential. RFC 7009 revocation at the top-level revocation_endpoint:
Mark it revoked, return 200, stay idempotent, and return 200 even for unknown or already-revoked tokens to prevent enumeration. The identity_assertion survives, so the agent can immediately mint a fresh access token. This is the small hammer.
The provider invalidates the registration itself. RFC 8935 push delivery of an RFC 8417 Security Event Token to the events_endpoint:
Verify the signature against the issuer's JWKS on the same trust path as ID-JAG verification, validate iss against the trust list and aud against your service, enforce jti uniqueness, then find all credentials issued for (iss, sub, aud) and invalidate them. Return 202 Accepted with no body on success, or 400 with { "err": "...", "description": "..." } on failure. Unknown event schemas can be safely ignored, which is what lets this surface grow into richer SET, CAEP, and RISC events over the same push channel without renegotiation.

This is the answer to who can turn it off. A user revoking an agent's access inside their agent provider's control plane propagates to every service that agent touched, without any of those services having built a bespoke integration.
Operational details that are easy to skip
Rate limiting. /agent/identity is unauthenticated for anonymous registration, so use two tiers checked in order. Per-IP first, roughly 5/hour anonymous and 60/hour for identity assertions, to stop one source from consuming the tenant's budget. Per-tenant second, roughly 100/hour and 1000/hour, as a global cap. Sliding window over a shared store, fail open on store errors, and skip the per-IP check rather than rejecting when a proxy has stripped the IP.
Audit events. Record registration.created, assertion.issued, token.issued, token.revoked, claim.requested, user_code.minted, claim.confirmed, registration.expired, and registration.revoked. For ID-JAG flows include iss, sub, agent_platform, and agent_context_id so operators can correlate against provider-side logs. If you already emit resource events for API keys, invitations, or memberships, tag them with created_by_agent: true and a status of unclaimed, claimed, or expired, so consumers don't have to cross-reference.
Secrets handling. claim_token, claim_attempt_token, and user_code are all bearer secrets with no proof of possession. Store SHA-256 hashes only. Plaintext leaves the server exactly once each. Generate the user_code with a CSPRNG, keep the TTL at ten minutes or less, and enforce tight per-claim retry limits at the form action, because a six-digit code is guess-bounded by lockout rather than entropy.
The anonymous claim boundary. Because the permission swap happens in place, anyone who captured the pre-claim credential retains access post-claim at the new scopes. Offer forced rotation as an opt-in for security-sensitive tenants, and provide an operator-facing bulk revocation path for incident response.
Implementing with AuthKit
If you'd rather not build the state machine, AuthKit ships it. Enrolling in the WorkOS Dashboard under Authentication → Agents generates the auth.md file agents read, and the Registrations tab lists every registration with its status of unverified, verified, expired, or revoked, plus the associated user and organization.
Which identity types you accept is a product decision about your data model. If your entities are owned by individual users, enable only service_auth, so every agent is bound to a person before it can act. If the resource owner is an organization, anonymous registrations work well, because agents can start against org-scoped resources immediately and bind a user later. When you do enable anonymous, give it a genuinely restricted untrusted permission set and reserve trusted permissions for claimed agents. Only permissions already enabled for organization API keys appear in that list.
Serve the generated file from your own root by reverse-proxying it, so it always reflects your live configuration:
Equivalent snippets exist for Nginx, Next.js, and Cloudflare Workers, and the hosted URL is public, so you can test the full flow before setting up hosting at all.
Agent access tokens carry iss, aud, sub (the registration ID), org_id, scope, jti, iat, exp, and an act claim identifying the user who authorized the agent, following the RFC 8693 delegation pattern. The act claim is present only after a claim ceremony has bound a user. Your API checks sub, org_id, and act in the token rather than trusting the agent's account of itself.
On validation, short-lived access tokens verify locally on signature and expiration with no network round trip. Call the validate credential endpoint only when you need revocation-before-expiry, or when you've configured durable API keys instead, which must be validated server-side.
If you'd rather not send users to AuthKit's hosted verification page, the standalone claim ceremony keeps them in your UI. Your server calls PATCH https://api.workos.com/agents/claims/attempts with the claim_attempt_token and the user's email and external_id, and gets back the user_code to render yourself. One caveat that deserves emphasis: AuthKit's hosted page signs the user in first, so when you take that step over, you take on that responsibility. Only reveal the code to a user your application has already authenticated. WorkOS enforces that the email matches the claim attempt, but it cannot verify who is looking at your screen.
And write the scope inventory in your published auth.md carefully. One line per scope, plus pointers to pricing, terms, and privacy, and a contact channel. That document is the closest thing an agent gets to your developer portal.
Choosing
If you serve IDE plugins and CLI clients that connect once and disappear, keep DCR running, set application_type expectations, and plan for the twelve-month deprecation runway rather than a cutover.
If you are an authorization server for open MCP traffic, implement CIMD now, with the SSRF checks and an admission policy for unknown domains, because the spec has already named it DCR's replacement and its client IDs are the only ones portable across authorization servers.
If agents are trying to become users of your product, meaning sign up, act on someone's behalf, and keep working tomorrow, client identity alone will not get you there. auth.md is the layer that adds the delegation, the per-step scopes, the step-up gate that keeps your existing sign-in policy in force, and the two revocation paths.
None of that is sequential. Advertise CIMD now, leave DCR on for the long tail of clients that have not caught up, and reach for auth.md once client identity is solid enough that the interesting question is delegation.
The tell is which question your on-call engineer cannot currently answer at 3am. "Which client is this?" is a CIMD problem. "Whose authority is this agent acting under, and who can turn it off?" is not.
Start with the auth.md spec and reference implementation on GitHub, or the integration guide for apps. Both are open, and neither needs a WorkOS account.
Sources
auth.md
- auth.md: integration guide for apps
- auth.md: integration guide for agent providers
- workos/auth.md reference implementation and spec
- Example auth.md file
- Agent Registration with auth.md
- AuthKit: Agent Registration
MCP specification
- MCP 2026-07-28: client registration
- MCP 2026-07-28: authorization security considerations
- MCP versioning
- SEP-2596: specification feature lifecycle and deprecation policy
- The 2026-07-28 specification
Standards and drafts
- RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol
- RFC 7592: OAuth 2.0 Dynamic Client Registration Management Protocol
- RFC 8414: OAuth 2.0 Authorization Server Metadata
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- RFC 7523: JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants
- RFC 8628: OAuth 2.0 Device Authorization Grant
- RFC 7009: OAuth 2.0 Token Revocation
- RFC 8417: Security Event Token (SET)
- RFC 8935: Push-Based Security Event Token Delivery Using HTTP
- RFC 8693 section 4.1: the
actdelegation claim - OAuth Client ID Metadata Document (draft)
- Identity Assertion JWT Authorization Grant, ID-JAG (draft)
- OpenID Connect Dynamic Client Registration 1.0