MCP auth for AI agents: How to register a Python OAuth client using CIMD
Build a CIMD-based confidential MCP client in Python using Authorization Code + PKCE.
You’re building an AI agent that needs to connect to MCP servers and call their tools. Before your agent can do any of that, it has to get OAuth tokens in a way servers can trust. In order for this to happen, the OAuth server must be able to safely identify who the client is, so every OAuth flow starts with one prerequisite: the client must be registered (in some form) so the authorization server knows its client_id, allowed redirect URIs, and how it should authenticate.
As of the MCP 2025-11-25 spec update, the recommended default for MCP client identity is Client ID Metadata Documents (CIMD): your agent uses a HTTPS URL as its client_id, and that URL serves a small JSON document describing the client (redirect URIs, auth method, and where the server can find your public keys). This avoids brittle per-server registrations and scales cleanly as your agent talks to more MCP servers.
In this tutorial, we’ll build a confidential MCP client in Python that uses CIMD end-to-end. We will see how to:
- Host a CIMD document and a JWKS endpoint for your agent’s public keys.
- Start an Authorization Code + login against an MCP server’s authorization server.
- Exchange the code at the token endpoint using
private_key_jwt(a signed client assertion). - End with tokens your agent can use to authenticate MCP requests and invoke tools.
What is CIMD for confidential clients?
A Client ID Metadata Document is a JSON document hosted by the client (your app). In CIMD, the document’s HTTPS URL is used directly as the OAuth client_id. When an MCP server receives a request with a URL client_id, it fetches the document to learn the client’s metadata (redirect URIs, grants, auth method, keys) on demand.
For confidential clients, CIMD replaces per-server secrets with a “bring your own keys” model:
- The client publishes
token_endpoint_auth_method: "private_key_jwt" - It also publishes a public key via
jwksorjwks_uri - The client signs a short-lived JWT to authenticate at
/token - The MCP server upon receiving the token verifies using the published keys
So the client has one stable identity and one keypair that works across all MCP servers supporting CIMD; no registration database and no secret sprawl.
Step-by-step flow (CIMD + auth code + private_key_jwt)
- The client hosts the CIMD JSON document at a stable HTTPS URL. That URL will be used as
client_id. This is done once and can be reused to access multiple MCP servers. This document includes:client_id: Must equal the URL of the JSON document.redirect_uris: These are the only allowed callback URLs (belonging to your app) where the authorization server may send codes or tokens.response_types,grant_types: These declare which OAuth flows the client is allowed to use and what it may request at the authorization endpoint.token_endpoint_auth_method: "private_key_jwt": This tells the server how the client will authenticate itself when exchanging the authorization code for tokens. It should beprivate_key_jwtfor confidential apps andnonefor public (ie., apps that cannot safely hold secrets, like single-page or mobile apps).jwks_uri(or inlinejwks): The client’s public keys so the server can verify JWTs signed by the client.
- Every time the client needs a new token in order to access an MCP server, it starts a new OAuth Authorization Code flow (with PKCE).The first step, is to send the user to the authorization server’s authorization endpoint with:
client_id = CIMD URL: This allows the server to locate and fetch the client’s CIMD metadata dynamically.redirect_uri = one of redirect_uris: This must exactly match a registered redirect URI (one included in the CIMD JSON document) to prevent token leakage to untrusted endpoints.response_type=code: This signals that the client is using the Authorization Code flow.- PKCE params:
code_challenge: This is a derived value (typically a hash) of the client’scode_verifierthat the server stores for later comparison. For details on how PKCE works see What is PKCE and why every OAuth app should use it.code_challenge_method=S256: This indicates the challenge was computed using SHA-256 (recommended), so the server knows how to verify it during the token exchange.
- The authorization server fetches and validates the CIMD JSON document. It verifies that:
- The
client_idis a fetchablehttps://…URL (not a bare string). - The fetched resource is valid JSON.
- The CIMD document’s
client_idexactly equals the URL used to fetch it. - The requested
redirect_uriis explicitly allowlisted inredirect_uris. It checks for an exact match (scheme/host/path/query, including trailing slashes) and rejects anything not listed, so codes can’t be sent to attacker-controlled callbacks. - The metadata is cached according to HTTP caching rules. It respects headers like
Cache-Control,ETag, andLast-Modifiedto decide when it can reuse cached CIMD vs re-fetch, balancing performance with timely updates/revocation.
- The
- Once the user authenticates and approves providing consent, the authorization server redirects back with a code.
- The client exchanges the code for a token using
private_key_jwt. To do so, it builds and signs aclient_assertionJWT and then POSTs it to the token endpoint of the authorization server, along with the code it got in the previous step. The JWT includes the following information:issandsub=client_id(CIMD URL)aud= token endpoint URL (this restricts the JWT so it can only be used at the intended token endpoint)- short
exp(expiration date) - unique
jti(allows the server to detect and reject replayed assertions)
- The server verifies the assertion. It fetches the public keys from
jwks_uri, verifies signature + claims, and issues tokens.
Implementing CIMD with Python
We’ll implement:
- A CIMD document + JWKS endpoint (client hosted)
- Client-side code to:
- fetch/validate server metadata
- construct authorization URL
- build & sign
private_key_jwt - exchange code for tokens
- Minimal CIMD validation helper (server-side)
We will use the following libraries:
cryptographyfor key generationPyJWTfor signingrequestsfor HTTP
Install them with:
1) Client: Generate a keypair and publish JWKS
You’ll generate an RSA keypair and publish the public part as JWKS. The auth server will later fetch this JWKS and use it to verify your client_assertion signature.Steps:
- Generate an RSA keypair
- Create a JWKS containing the public key with a stable
kid - Save
jwks.jsonsomewhere you can serve via HTTPS
Python sample code (generate keypair + JWKS):
Take the printed JWKS JSON, host it at your jwks_uri. For the rest of this tutorial, we will assume that the JWKS URL is https://ai.example.com/oauth/jwks.json.
2) Client: Generate the CIMD JSON document
Now you’ll generate the CIMD JSON document that you will host at a stable HTTPS URL. That URL becomes your client_id, and the document declares your redirect_uris, flow support (response_types, grant_types), token auth method (private_key_jwt), and where the auth server can fetch your keys (jwks_uri).
The URL we will use is https://ai.example.com/oauth/client.json.
Our CIMD JSON doc will contain the following information:
Python sample code (generate CIMD JSON):
3) Client: Publish the CIMD document and JWKS
Both client.json and jwks.json must be hosted at stable URLs (ideally behind HTTPS) so the auth server can fetch them during client “discovery”.Steps:
- Publish:
client.jsonat yourclient_idURLjwks.jsonat yourjwks_uriURL
- Ensure the
client_idinsideclient.jsonmatches the hosted URL exactly
Quick local publishing (for development only):
Then you can temporarily use (dev-only):
http://localhost:8080/oauth/client.jsonhttp://localhost:8080/oauth/jwks.json
For real flows, you’ll want HTTPS and publicly reachable URLs, since servers generally won’t fetch localhost CIMD/JWKS. CIMD and JWKS endpoints may need CORS headers.
4) Client: Discover the MCP server’s authorization server + endpoints
Before you start an OAuth flow, you need to know the authorization endpoint and token endpoint (and sometimes issuer, JWKS, etc.). This information is exposed via two OAuth discovery documents:
- The MCP server’s Protected Resource Metadata (
/.well-known/oauth-protected-resource) tells you which authorization server(s) can issue tokens for that MCP server. - The Authorization Server Metadata (
/.well-known/oauth-authorization-server) tells you the exactauthorization_endpoint,token_endpoint, etc., that you should be calling.
There are two common deployments:
- Option A: MCP server runs its own OAuth (MCP server == authorization server). The MCP server’s protected resource metadata lists an
authorization_serversentry that points back to the same domain (the server’s own issuer), and the client then fetches/.well-known/oauth-authorization-serverfrom that same issuer. - Option B: MCP server delegates OAuth to another service (e.g., WorkOS/AuthKit). The MCP server’s protected resource metadata lists the external authorization server (for example, something like
https://<subdomain>.authkit.app), and the client fetches authorization server metadata from that external issuer.
Steps:
- Get the authorization server discovery URL from the MCP server docs/config
- Fetch discovery metadata and extract:
authorization_endpointtoken_endpoint- (optionally)
issuer,jwks_uri, supportedtoken_endpoint_auth_methods
Step 4a) Fetch the MCP server’s protected resource metadata
Sample request (same for both options):
What you’re looking for in the response:
authorization_servers: list of issuer URLs you can use for OAuth with this resource- (often also)
resourceandjwks_urifor validating access tokens at the resource server side
The discovery metadata you will fetch will look like this:
Step 4b) Fetch authorization server metadata
After you get the server’s URL from authorization_servers you are ready to fetch its metadata (/.well-known/oauth-authorization-server) to get authorization_endpoint, token_endpoint, etc.
Sample code:
The Authorization Server’s Metadata response looks like this (WorkOS example):
Here’s what each metadata field means:
authorization_endpoint: The endpoint where the client redirects the user to start the OAuth Authorization Code flow.code_challenge_methods_supported: PKCE methods supported by the server.S256indicates the server requires or supports SHA-256–based PKCE challenges.grant_types_supported: The OAuth grant types this authorization server allows. Here, the client can useauthorization_codeto get tokens andrefresh_tokento renew them.introspection_endpoint: An endpoint that allows resource servers to validate or inspect issued access tokens. This is typically used when the resource server cannot validate tokens locally (for example, when tokens are opaque rather than JWTs).issuer: The canonical identifier for this authorization server. Clients use this value as the trust anchor and must ensure it matches the issuer they discovered via the MCP server.registration_endpoint: An endpoint for dynamic client registration, used when the authorization server supports registering OAuth clients programmatically.scopes_supported: The scopes this authorization server understands and may issue tokens for. Clients should only request scopes listed here.response_modes_supported: How the authorization response is returned to the client.querymeans the authorization code is delivered as a query parameter on the redirect URI.response_types_supported: The response types the authorization endpoint supports.codeindicates standard Authorization Code flow.token_endpoint: The endpoint the client calls to exchange an authorization code (and later refresh tokens) for access tokens.token_endpoint_auth_methods_supported: Lists the ways a client is allowed to authenticate itself when calling the token endpoint to exchange an authorization code for tokens. The client must use one of the listed methods, and the method it chooses must match the method by which the client was registered with the authorization server. Common options include:none: Used by public clients that cannot keep credentials secret (for example, browser-based clients), so the client is identified only by itsclient_id.client_secret_basic: The client authenticates using an HTTPAuthorization: Basicheader with itsclient_idandclient_secret.client_secret_post: The client sends itsclient_idandclient_secretin the POST body of the token request.
5) Client: Start an OAuth flow (with PKCE)
Now your app (the client) can start an OAuth flow by redirecting the user to the authorization endpoint with the usual parameters plus PKCE.Steps:
- Generate
code_verifierandcode_challenge - Redirect the user to
authorization_endpointwith:client_id= CIMD URLredirect_uri= one ofredirect_urisresponse_type=codecode_challenge+code_challenge_method=S256
After the user approves, your redirect handler will receive code and state.
6) AuthZ Server: Fetches and validates CIMD
When the client hits the authorization endpoint with client_id=<CIMD URL>, the authorization server typically does something like:
- Fetch the CIMD document: Performs an HTTPS
GETto theclient_idURL (your hostedclient.json). May follow redirects only if its policy allows (many servers are strict here). - Validate it’s actually CIMD JSON: Confirms the body parses as JSON (and often checks
Content-Typeis JSON). Rejects HTML/login pages, non-JSON responses, or malformed JSON. - Bind identity:
client_idmust match exactly: Compares the fetched document’sclient_idto the request’sclient_idbyte-for-byte (same URL, same casing, same trailing slash, same query string if present). This prevents “host metadata at A but claim identity B”. - Enforce redirect allowlisting: Verifies the
redirect_uriin the authorize request matches one entry inredirect_urisexactly (no partial matches). If it doesn’t match, it rejects the request (this is the main defense against redirect-based token theft). - Check flow compatibility: Ensures
response_typesincludescodeandgrant_typesincludesauthorization_codefor this flow. If you’ll later useprivate_key_jwt, it checkstoken_endpoint_auth_methodis compatible with what you’re going to do. - Cache the result: Uses HTTP caching headers (
Cache-Control,ETag,Last-Modified) to avoid re-fetching on every authorize request, but still revalidate when needed.
If the Authorization Server’s validation fails, your app will receive an OAuth error redirect back to the redirect_uri (for example ?error=invalid_request&error_description=...). In this case the client must:
- Confirm the
client_idis fetchable and stable - Confirm
client.jsonhasclient_idequal to its URL - Confirm
redirect_uriexactly matches one entry
You can add preflight checks to catch most “server will reject me” issues early. Sample code:
7) Client: User authenticates and approves
If the user approves, the authorization server redirects back to your redirect_uri with an authorization code (and usually echoes back state if you sent one).
Sample response:
Response params:
code: short-lived authorization code your client exchanges at the token endpointstate: your CSRF correlation value you must verify matches what you originally sent
8) Client: Build and sign a client assertion JWT (private_key_jwt)
In this step, you’ll create the client credential used at the token endpoint. Instead of sending a client_secret, the client proves its identity by sending a signed JWT called client_assertion. The authorization server verifies that JWT using the public key it fetched from your jwks_uri (published earlier), and if everything checks out (signature + claims like iss, sub, aud, exp, jti), it treats the client as authenticated.
Steps:
- Load your client’s private key (
client_private_key.pem) - Create JWT claims where:
issandsubare yourclient_id(the CIMD URL)audis the token endpoint URLexpis short-lived andjtiis unique (replay protection)
- Sign the JWT with
RS256(and optionally include akidheader that matches your JWKS)
Sample code:
9) Client: Exchange the authorization code for tokens
Now you’ll trade the short-lived authorization code (returned to your redirect_uri) for real tokens at the token endpoint. This request does three important things at once:
- Proves you’re the same client that started the flow by sending the original PKCE
code_verifier - Proves the client’s identity using your signed
client_assertion(private_key_jwt) - Requests tokens by presenting the authorization code and the same
redirect_uriyou used earlier
If any of those don’t line up (wrong redirect_uri, stale/used code, mismatched code_verifier, bad aud/iss/kid), the token endpoint will reject the request.
10) AuthZ Server: Verifies the assertion and issues tokens
At this point, the authorization server has everything it needs to verify both the OAuth transaction and the client’s identity before minting tokens.What the authorization server does (high-level):
- Validates the code exchange inputs
- Confirms the
codeis valid, unexpired, and not already used. - Confirms the
redirect_urimatches the one bound to the authorization code. - Validates PKCE by hashing the provided
code_verifierand comparing it to the originalcode_challengestored for that code.
- Confirms the
- Authenticates the client with
private_key_jwt- Parses
client_assertionas a JWT and checks it’s signed with an allowed algorithm (for exampleRS256). - Fetches the client’s public key set from
jwks_uri(from CIMD), selects the correct key (typically via the JWT headerkid), and verifies the signature. - Validates required JWT claims:
issandsubequal the client’sclient_id(the CIMD URL) exactlyaudequals the token endpoint URL exactlyexpis in the future (and short-lived)jtihasn’t been used before (replay protection), if enforced
- Parses
- Issues tokens: If everything checks out, the server returns an access token (and optionally refresh token, ID token, etc.) with scopes tied to what the user approved and what the client is allowed to request.
Sample success response:
Sample error response:
11) MCP Server: Validates access token before serving requests
Before the MCP server returns any protected data or executes tool calls, it should validate the presented bearer token. Conceptually, it does the reverse of what the authorization server did when issuing it: make sure the token is real, intended for this resource, and sufficiently privileged.
Verifying and validating a JWT includes the following steps.
- Parse the JWT to extract the header, payload, and signature: Read the header to learn which algorithm was used and which key ID (
kid) to select from the issuer’s JWKS. - Verify the signature using the secret key or public key: For asymmetric JWTs (common), the MCP server fetches the issuer’s JWKS, selects the public key by
kid, and verifies the signature (reject if it doesn’t verify). - Check
expandnbfto ensure the JWT is currently valid: Reject tokens that are expired (expin the past) or not yet valid (nbfin the future), allowing a small clock-skew leeway. - Verify
issto ensure the JWT was issued by a trusted party:issmust match the expected authorization server issuer exactly (the one discovered via metadata), not whatever the token claims. - Verify
audto ensure the JWT is intended for the correct recipient:audmust include the MCP server’s expected audience identifier (often the MCP resource URL or an API identifier you configure).
For more details, see JWT validation: how-to and best libraries to use.
Sample code (PyJWT + JWKS):
Wrap-up and next steps
You now have an end-to-end CIMD-based OAuth client for MCP:
- A stable HTTPS
client_idthat serves a CIMD JSON document (so servers can discover your redirect URIs, grants, auth method, and key location on demand). - A published JWKS endpoint so authorization servers can verify your
private_key_jwtclient assertions without shared secrets. - A complete Authorization Code + PKCE flow that returns tokens your agent can present to MCP servers.
- A clear model for how MCP servers validate JWT access tokens (signature +
exp/nbf+iss+aud) before invoking tools or returning protected resources.
If you want to go deeper, here are the most useful follow-ons:
- A developer’s guide to MCP auth
- Client ID Metadata Documents (CIMD): How OAuth client registration works in MCP
- JWT validation: how-to and best libraries to use
- Authorization in Python: Best practices and patterns that won’t bite you later
- WorkOS AuthKit Quickstart for Python
Finally, a practical production checklist you can keep nearby:
- Make
client_id,jwks_uri, andredirect_urisstable and exact-match safe (no “almost the same” URLs). - Rotate keys intentionally (publish new key in JWKS first, then start signing with it; keep old key until tokens/assertions age out).
- Treat every mismatch as a clue: most OAuth/CIMD failures come down to string-exact URL equality (
client_id,redirect_uri,aud,iss/sub).