Skip to content

Client authentication

A client proves its identity at the back-channel endpoints — /token and /par. Four methods are supported, advertised in discovery as token_endpoint_auth_methods_supported:

MethodCredentialFor
noneNone; PKCE onlySPAs, native apps, MCP clients
client_secret_basicSecret in the Authorization headerServer-side apps
client_secret_postSecret in the form bodyServer-side apps whose stack cannot set the header
private_key_jwtA signed JWT assertionAgents and services that should hold no shared secret

Assertion signing accepts ES256, EdDSA, and RS256.

A public client has no secret. It presents its client_id, and PKCE is what binds the authorization code to the client instance that started the flow:

POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=…&client_id=client_7f3a…&code_verifier=…&redirect_uri=…

This is the right choice for anything running on a device you do not control. A secret shipped in a browser bundle or a mobile binary is not a secret.

Confidential clients authenticate with client_secret_basic (preferred) or client_secret_post:

POST /token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=tool:search

Secrets are compared against a stored hash in constant time, and are returned exactly once at creation. Rotate an agent’s secret with POST /v1/organizations/:orgId/agents/:clientId/rotate_secret.

A confidential client must authenticate even on the authorization-code and refresh grants. PKCE alone is not sufficient for a client that has a registered secret — failing to present it returns 401 invalid_client.

The strongest option, and the right default for agents and services: the client registers a JWK Set and authenticates with a JWT it signs itself. No shared secret exists to leak, and nothing long-lived crosses the wire.

Register with the JWKS in place of a secret:

POST /register
Content-Type: application/json
{
"client_name": "Indexing service",
"client_type": "agent",
"grant_types": ["client_credentials"],
"token_endpoint_auth_method": "private_key_jwt",
"jwks": { "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "", "kid": "svc-1" } ] }
}

Then authenticate by signing an assertion (RFC 7523):

{
"iss": "client_7f3a…",
"sub": "client_7f3a…",
"aud": "https://acme.oauth.work/token",
"jti": "<unique per assertion>",
"exp": 1750000060
}
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed JWT>

The assertion is audience-bound to the token endpoint and its jti is single-use, so a captured assertion cannot be replayed. Pair it with DPoP and neither the client’s credential nor its issued token is useful to anyone who intercepts them.

RFC 7591. POST /register needs no credential, which is what lets an MCP client register itself the first time a user connects it:

POST /register
Content-Type: application/json
{
"client_name": "Research Agent",
"redirect_uris": ["https://agent.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "openid offline_access"
}

Because registration is anonymous, it is deliberately constrained:

  • Grants. Ordinary clients may register authorization_code and refresh_token only. Clients registering as client_type: "agent" may additionally request client_credentials.
  • Redirect URIs. Required for the authorization-code grant, and must be https, http loopback, or a safe custom scheme. Anonymous registration of a non-agent client with a custom scheme is refused outright — that shape is a phishing surface.
  • Scopes. First-party scopes only. A dynamically registered client cannot request tool scopes; those require registering the agent through the authenticated management API.
  • No org binding. Dynamically registered clients are never bound to an organization, because an org-bound client’s tokens carry org_id and can mint org-signed credentials. Org binding happens only through an authenticated /v1 call.

A client_secret is returned when the requested auth method implies one; like every other secret, once.

An MCP or agent client may use an https URL as its client_id. The authorization server fetches that URL for the client’s metadata rather than requiring registration at all — domain control is the identity. Advertised in discovery as client_id_metadata_document_supported: true.

Host a document at the URL you intend to use as your client id:

{
"client_id": "https://agent.example.com/oauth-client.json",
"client_name": "Research Agent",
"redirect_uris": ["https://agent.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none"
}

Then use that URL directly:

GET /authorize?client_id=https://agent.example.com/oauth-client.json&…

The rules follow from what a URL can and cannot do:

  • The document’s client_id must equal its own URL. Otherwise one document could claim to be another client.
  • URL clients are always public. A URL cannot keep a secret, so token_endpoint_auth_method must be none and PKCE is enforced.
  • authorization_code and refresh_token only, first-party scopes only, no org binding — the same posture as anonymous registration.
  • The document is cached for 5 minutes, so a compromised or corrected document ages out fast.
SituationUse
SPA or mobile appnone + PKCE
Server-side web appclient_secret_basic
Agent or service you provisionprivate_key_jwt, registered via /v1 agents
MCP client connecting to many serversURL client id, or dynamic registration