In this article
December 15, 2025
December 15, 2025

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 jwks or jwks_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)

  1. 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 be private_key_jwt for confidential apps and none for public (ie., apps that cannot safely hold secrets, like single-page or mobile apps).
    • jwks_uri (or inline jwks): The client’s public keys so the server can verify JWTs signed by the client.
  2. 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’s code_verifier that 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.
  3. The authorization server fetches and validates the CIMD JSON document. It verifies that:
    • The client_id is a fetchable https://… URL (not a bare string).
    • The fetched resource is valid JSON.
    • The CIMD document’s client_id exactly equals the URL used to fetch it.
    • The requested redirect_uri is explicitly allowlisted in redirect_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, and Last-Modified to decide when it can reuse cached CIMD vs re-fetch, balancing performance with timely updates/revocation.
  4. Once the user authenticates and approves providing consent, the authorization server redirects back with a code.
  5. The client exchanges the code for a token using private_key_jwt. To do so, it builds and signs a client_assertion JWT 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:
    • iss and sub = 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)
  6. 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:

  1. A CIMD document + JWKS endpoint (client hosted)
  2. Client-side code to:
    • fetch/validate server metadata
    • construct authorization URL
    • build & sign private_key_jwt
    • exchange code for tokens
  3. Minimal CIMD validation helper (server-side)

We will use the following libraries:

  • cryptography for key generation
  • PyJWT for signing
  • requests for HTTP

Install them with:

  
pip install cryptography PyJWT requests
  

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:

  1. Generate an RSA keypair
  2. Create a JWKS containing the public key with a stable kid
  3. Save jwks.json somewhere you can serve via HTTPS

Python sample code (generate keypair + JWKS):

  
import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

def b64url_uint(val: int) -> str:
    if val == 0:
        return "AA"  # base64url encoding of single zero byte
    b = val.to_bytes((val.bit_length() + 7) // 8, "big")
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")

# Generate RSA key
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = key.public_key()

numbers = public_key.public_numbers()
jwks = {
    "keys": [{
        "kty": "RSA",
        "kid": "key-1",           # any stable key id you choose
        "use": "sig",
        "alg": "RS256",
        "n": b64url_uint(numbers.n),
        "e": b64url_uint(numbers.e),
    }]
}

# Save private key securely (DON'T publish)
private_pem = key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
)
with open("client-private-key.pem", "wb") as f:
    f.write(private_pem)

# Publish JWKS at jwks_uri
print(json.dumps(jwks, indent=2))
  

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:

  
{
  "client_id": "https://ai.example.com/oauth/client.json",
  "client_name": "Acme MCP Agent",
  "redirect_uris": ["https://ai.example.com/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "private_key_jwt",
  "jwks_uri": "https://ai.example.com/oauth/jwks.json"
}
  

Python sample code (generate CIMD JSON):

  
import json

CLIENT_ID = "https://ai.example.com/oauth/client.json"   # CIMD URL (must equal this exact value)
JWKS_URI  = "https://ai.example.com/oauth/jwks.json"

REDIRECT_URIS = [
    "https://ai.example.com/callback"  # update for your app
]

cimd = {
    "client_id": CLIENT_ID,
    "redirect_uris": REDIRECT_URIS,
    "response_types": ["code"],
    "grant_types": ["authorization_code"],
    "token_endpoint_auth_method": "private_key_jwt",
    "token_endpoint_auth_signing_alg": "RS256",
    "jwks_uri": JWKS_URI,
    # Optional but often useful:
    # "client_name": "My MCP Python Client",
    # "scope": "openid profile offline_access",
}

with open("client.json", "w") as f:
    json.dump(cimd, f, indent=2)

print("Wrote client.json (CIMD)")
  

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:

  1. Publish:
    • client.json at your client_id URL
    • jwks.json at your jwks_uri URL
  2. Ensure the client_id inside client.json matches the hosted URL exactly

Quick local publishing (for development only):

  
python -m http.server 8080
  

Then you can temporarily use (dev-only):

  • http://localhost:8080/oauth/client.json
  • http://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:

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_servers entry that points back to the same domain (the server’s own issuer), and the client then fetches /.well-known/oauth-authorization-server from 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:

  1. Get the authorization server discovery URL from the MCP server docs/config
  2. Fetch discovery metadata and extract:
    • authorization_endpoint
    • token_endpoint
    • (optionally) issuer, jwks_uri, supported token_endpoint_auth_methods

Step 4a) Fetch the MCP server’s protected resource metadata

Sample request (same for both options):

  
import requests

MCP_SERVER_BASE = "https://mcp.example.com"  # base origin where /.well-known lives
prm_url = f"{MCP_SERVER_BASE}/.well-known/oauth-protected-resource"

prm = requests.get(prm_url, timeout=10).json()
print("authorization_servers:", prm.get("authorization_servers"))
  

What you’re looking for in the response:

  • authorization_servers: list of issuer URLs you can use for OAuth with this resource
  • (often also) resource and jwks_uri for validating access tokens at the resource server side

The discovery metadata you will fetch will look like this:

  
{
  "resource": "https://api.example.com/mcp",
  "authorization_servers": ["https://example.authkit.app"],
  "bearer_methods_supported": ["header"],
  "jwks_uri": "https://api.example.com/.well-known/jwks.json"
}
  

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:

  
import requests

# From PRM:
auth_server_issuer = "https://example.authkit.app"

as_meta_url = f"{auth_server_issuer}/.well-known/oauth-authorization-server"
as_meta = requests.get(as_meta_url, timeout=10).json()

authorization_endpoint = as_meta["authorization_endpoint"]
token_endpoint = as_meta["token_endpoint"]

print("authorization_endpoint:", authorization_endpoint)
print("token_endpoint:", token_endpoint)
  

The Authorization Server’s Metadata response looks like this (WorkOS example):

  
{
  "authorization_endpoint": "https://<subdomain>.authkit.app/oauth2/authorize",
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "introspection_endpoint": "https://<subdomain>.authkit.app/oauth2/introspection",
  "issuer": "https://<subdomain>.authkit.app",
  "registration_endpoint": "https://<subdomain>.authkit.app/oauth2/register",
  "scopes_supported": ["email", "offline_access", "openid", "profile"],
  "response_modes_supported": ["query"],
  "response_types_supported": ["code"],
  "token_endpoint": "https://<subdomain>.authkit.app/oauth2/token",
  "token_endpoint_auth_methods_supported": [
    "none",
    "client_secret_post",
    "client_secret_basic"
  ]
}
  

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. S256 indicates 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 use authorization_code to get tokens and refresh_token to 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. query means the authorization code is delivered as a query parameter on the redirect URI.
  • response_types_supported: The response types the authorization endpoint supports. code indicates 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 its client_id.
    • client_secret_basic: The client authenticates using an HTTP Authorization: Basic header with its client_id and client_secret.
    • client_secret_post: The client sends its client_id and client_secret in 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:

  1. Generate code_verifier and code_challenge
  2. Redirect the user to authorization_endpoint with:
    • client_id = CIMD URL
    • redirect_uri = one of redirect_uris
    • response_type=code
    • code_challenge + code_challenge_method=S256
  
import os, hashlib, base64, urllib.parse

CLIENT_ID = "https://ai.example.com/oauth-client.json"  # CIMD URL
REDIRECT_URI = "https://ai.example.com/callback"
SCOPE = "mcp.tools.read mcp.resources.read"

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")

# PKCE
code_verifier = b64url(os.urandom(32))
code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())

state = b64url(os.urandom(16))

params = {
    "response_type": "code",
    "client_id": CLIENT_ID,
    "redirect_uri": REDIRECT_URI,
    "scope": SCOPE,
    "state": state,
    "code_challenge": code_challenge,
    "code_challenge_method": "S256",
}

authorize_url = AUTHZ_ENDPOINT + "?" + urllib.parse.urlencode(params)
print("Open this in a browser:\n", authorize_url)
  

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:

  1. Fetch the CIMD document: Performs an HTTPS GET to the client_id URL (your hosted client.json). May follow redirects only if its policy allows (many servers are strict here).
  2. Validate it’s actually CIMD JSON: Confirms the body parses as JSON (and often checks Content-Type is JSON). Rejects HTML/login pages, non-JSON responses, or malformed JSON.
  3. Bind identity: client_id must match exactly: Compares the fetched document’s client_id to the request’s client_id byte-for-byte (same URL, same casing, same trailing slash, same query string if present). This prevents “host metadata at A but claim identity B”.
  4. Enforce redirect allowlisting: Verifies the redirect_uri in the authorize request matches one entry in redirect_uris exactly (no partial matches). If it doesn’t match, it rejects the request (this is the main defense against redirect-based token theft).
  5. Check flow compatibility: Ensures response_types includes code and grant_types includes authorization_code for this flow. If you’ll later use private_key_jwt, it checks token_endpoint_auth_method is compatible with what you’re going to do.
  6. 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_id is fetchable and stable
  • Confirm client.json has client_id equal to its URL
  • Confirm redirect_uri exactly matches one entry

You can add preflight checks to catch most “server will reject me” issues early. Sample code:

  
import json
import requests
from urllib.parse import urlparse

def preflight_cimd(client_id_url: str, redirect_uri: str, timeout: int = 10):
    # 1) Must be HTTPS in most real deployments
    parsed = urlparse(client_id_url)
    if parsed.scheme != "https":
        raise ValueError(f"client_id must be https:// in production. Got: {client_id_url}")

    # 2) Fetch CIMD
    r = requests.get(client_id_url, timeout=timeout, headers={"Accept": "application/json"})
    r.raise_for_status()

    try:
        cimd = r.json()
    except json.JSONDecodeError as e:
        raise ValueError(f"CIMD is not valid JSON at {client_id_url}: {e}")

    # 3) Exact client_id match
    doc_client_id = cimd.get("client_id")
    if doc_client_id != client_id_url:
        raise ValueError(
            "CIMD client_id mismatch:\n"
            f"- requested client_id: {client_id_url}\n"
            f"- document client_id:  {doc_client_id}\n"
            "These must match exactly."
        )

    # 4) Exact redirect match
    redirect_uris = cimd.get("redirect_uris", [])
    if redirect_uri not in redirect_uris:
        raise ValueError(
            "redirect_uri not allowlisted in CIMD:\n"
            f"- requested redirect_uri: {redirect_uri}\n"
            f"- allowlisted: {redirect_uris}"
        )

    # 5) Flow support sanity checks (optional but helpful)
    if "code" not in cimd.get("response_types", []):
        raise ValueError("CIMD response_types must include 'code' for auth code flow.")
    if "authorization_code" not in cimd.get("grant_types", []):
        raise ValueError("CIMD grant_types must include 'authorization_code'.")

    return cimd
  

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:

  
http://localhost:8000/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
  

Response params:

  • code: short-lived authorization code your client exchanges at the token endpoint
  • state: 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:

  1. Load your client’s private key (client_private_key.pem)
  2. Create JWT claims where:
    • iss and sub are your client_id (the CIMD URL)
    • aud is the token endpoint URL
    • exp is short-lived and jti is unique (replay protection)
  3. Sign the JWT with RS256 (and optionally include a kid header that matches your JWKS)

Sample code:

  
# pip install pyjwt cryptography
import time
import uuid
import jwt  # PyJWT

def build_client_assertion(
    *,
    client_id: str,
    token_endpoint: str,
    private_key_pem_path: str,
    kid: str | None = None,
) -> str:
    now = int(time.time())
    claims = {
        "iss": client_id,
        "sub": client_id,
        "aud": token_endpoint,
        "iat": now,
        "exp": now + 60,           # short-lived
        "jti": str(uuid.uuid4()),  # replay protection
    }

    with open(private_key_pem_path, "rb") as f:
        key = f.read()

    headers = {"kid": kid} if kid else None
    return jwt.encode(claims, key, algorithm="RS256", headers=headers)
  

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_uri you 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.

  
# pip install requests
import requests

def exchange_code_for_tokens(
    *,
    token_endpoint: str,
    client_id: str,          # CIMD URL
    redirect_uri: str,
    code: str,
    code_verifier: str,
    client_assertion_jwt: str,
    timeout_s: int = 15,
) -> dict:
    data = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirect_uri,
        "client_id": client_id,

        # PKCE
        "code_verifier": code_verifier,

        # private_key_jwt (RFC 7523 style)
        "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        "client_assertion": client_assertion_jwt,
    }

    resp = requests.post(
        token_endpoint,
        data=data,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=timeout_s,
    )

    # Success
    if resp.status_code == 200:
        return resp.json()

    # Failure: surface server payload + give actionable hints
    try:
        payload = resp.json()
    except Exception:
        payload = {"raw": resp.text}

    error = payload.get("error", "")
    desc = payload.get("error_description", "")

    hints = []
    if error == "invalid_grant":
        hints.append("The `code` may be expired or already used; restart the auth flow to get a fresh code.")
        hints.append("Confirm `redirect_uri` exactly matches the one used in /authorize and listed in CIMD.")
        hints.append("Confirm `code_verifier` matches the original PKCE verifier used to compute the challenge.")
    if error in ("invalid_client", "unauthorized_client"):
        hints.append("Confirm JWT `aud` equals the token endpoint URL exactly.")
        hints.append("Confirm JWT `iss` and `sub` equal `client_id` (your CIMD URL) exactly.")
        hints.append("Confirm JWT header `kid` matches a key in your published JWKS.")
        hints.append("Confirm the server can fetch `jwks_uri` and the JWT header `kid` matches a published key.")
    if not hints:
        hints.append("Inspect the error payload and re-check CIMD fields + JWT claims for exact matches.")

    raise RuntimeError(
        "Token exchange failed\n"
        f"HTTP {resp.status_code}\n"
        f"error: {error}\n"
        f"error_description: {desc}\n"
        f"response: {payload}\n"
        "Troubleshooting:\n- " + "\n- ".join(hints)
    )
  

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

  1. Validates the code exchange inputs
    • Confirms the code is valid, unexpired, and not already used.
    • Confirms the redirect_uri matches the one bound to the authorization code.
    • Validates PKCE by hashing the provided code_verifier and comparing it to the original code_challenge stored for that code.
  2. Authenticates the client with private_key_jwt
    • Parses client_assertion as a JWT and checks it’s signed with an allowed algorithm (for example RS256).
    • Fetches the client’s public key set from jwks_uri (from CIMD), selects the correct key (typically via the JWT header kid), and verifies the signature.
    • Validates required JWT claims:
      • iss and sub equal the client’s client_id (the CIMD URL) exactly
      • aud equals the token endpoint URL exactly
      • exp is in the future (and short-lived)
      • jti hasn’t been used before (replay protection), if enforced
  3. 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:

  
{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def50200...",
  "scope": "openid profile email offline_access"
}
  

Sample error response:

  
{
  "error": "invalid_client",
  "error_description": "client_assertion validation failed"
}
  

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.

  1. 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.
  2. 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).
  3. Check exp and nbf to ensure the JWT is currently valid: Reject tokens that are expired (exp in the past) or not yet valid (nbf in the future), allowing a small clock-skew leeway.
  4. Verify iss to ensure the JWT was issued by a trusted party: iss must match the expected authorization server issuer exactly (the one discovered via metadata), not whatever the token claims.
  5. Verify aud to ensure the JWT is intended for the correct recipient: aud must 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):

  
# pip install pyjwt cryptography requests
import json
import requests
import jwt
from jwt import PyJWKClient

def validate_access_token_jwt(
    *,
    token: str,
    issuer: str,              # expected iss, e.g. "https://<subdomain>.authkit.app"
    audience: str,            # expected aud, e.g. "https://mcp.example.com/mcp" or API audience string
    jwks_uri: str,            # issuer JWKS URI, from AS metadata
    leeway_s: int = 60,       # clock skew tolerance
) -> dict:
    """
    Returns the decoded JWT claims if valid, otherwise raises jwt exceptions.
    Implements:
      1) parse header/payload/signature
      2) verify signature using issuer JWKS
      3) validate exp/nbf (and iat) with leeway
      4) validate iss
      5) validate aud
    """

    # (1) Parse (no verification yet) to extract header fields like kid/alg
    unverified_header = jwt.get_unverified_header(token)
    alg = unverified_header.get("alg")
    kid = unverified_header.get("kid")

    if alg is None or alg == "none":
        raise jwt.InvalidAlgorithmError("Missing/invalid alg in JWT header")

    # (2) Verify signature using issuer public key from JWKS
    jwk_client = PyJWKClient(jwks_uri)
    signing_key = jwk_client.get_signing_key_from_jwt(token).key  # picks by kid if present

    # (3,4,5) Decode + verify standard claims
    # - `verify_exp` and `verify_nbf` are on by default; leeway applies to exp/nbf/iat
    claims = jwt.decode(
        token,
        signing_key,
        algorithms=[alg],
        audience=audience,
        issuer=issuer,
        leeway=leeway_s,
        options={
            "require": ["exp", "iss", "iat"],  # add "aud" if your tokens always include it
        },
    )

    return claims


# Example usage inside an MCP handler:
def authorize_request(auth_header: str) -> dict:
    if not auth_header.startswith("Bearer "):
        raise PermissionError("Missing Bearer token")

    token = auth_header[len("Bearer "):].strip()

    claims = validate_access_token_jwt(
        token=token,
        issuer="https://<subdomain>.authkit.app",
        audience="https://mcp.example.com/mcp",
        jwks_uri="https://<subdomain>.authkit.app/.well-known/jwks.json",
    )

    # Optional: enforce scopes/permissions for MCP operations
    scopes = set((claims.get("scope") or "").split())
    if "openid" not in scopes:  # replace with your required scope(s)
        raise PermissionError("Insufficient scope")

    return claims
  

Wrap-up and next steps

You now have an end-to-end CIMD-based OAuth client for MCP:

  • A stable HTTPS client_id that 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_jwt client 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:

Finally, a practical production checklist you can keep nearby:

  • Make client_id, jwks_uri, and redirect_uris stable 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).

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.