claude-code - 💡(How to fix) Fix [BUG] OAuth Token Exchange not completed in 2.1.143 with non-DCR AS (Cloudflare Access for SaaS) — server side curl-verified, regression suspected from 2.1.80

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

from mcp.server.fastmcp import FastMCP from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings from pydantic import AnyHttpUrl from jose import jwt from jose.exceptions import JWTError import httpx, time, os

class CFTokenVerifier(TokenVerifier): # JWKS-based RS256 verification, issuer check, email allowlist # audience check disabled (Cloudflare aud = redirect_uri, non-standard) def init(self, team_domain, app_id, allowed_email): self._issuer = f"https://{team_domain}/cdn-cgi/access/sso/oidc/{app_id}" self._jwks_url = f"{self._issuer}/jwks" self._allowed_email = allowed_email self._jwks = None; self._fetched = 0

   async def _get_jwks(self):
       if not self._jwks or time.time() - self._fetched > 3600:
           async with httpx.AsyncClient(timeout=10) as c:
               r = await c.get(self._jwks_url); r.raise_for_status()
               self._jwks = r.json(); self._fetched = time.time()
       return self._jwks

   async def verify_token(self, token):
       try:
           jwks = await self._get_jwks()
           kid = jwt.get_unverified_header(token).get("kid")
           key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
           if not key: return None
           claims = jwt.decode(token, key, algorithms=["RS256"],
                              issuer=self._issuer,
                              options={"verify_aud": False})
           if claims.get("email") != self._allowed_email: return None
           return AccessToken(token=token, client_id=claims.get("aud"),
                              scopes=["openid"], expires_at=claims.get("exp"))
       except (JWTError, Exception):
           return None

verifier = CFTokenVerifier(os.environ["CF_TEAM_DOMAIN"], os.environ["CF_OIDC_APP_ID"], os.environ["MCP_ALLOWED_EMAIL"])

mcp = FastMCP( "minimal-mcp", host="127.0.0.1", port=8000, token_verifier=verifier, auth=AuthSettings( issuer_url=AnyHttpUrl(verifier._issuer), resource_server_url=AnyHttpUrl(f"https://{os.environ['MCP_PUBLIC_HOST']}/mcp"), required_scopes=["openid"], ), )

@mcp.tool() def ping(message: str = "hello") -> str: return f"pong: {message}"

if name == "main": mcp.run(transport="streamable-http")

Root Cause

[MCP server side — stderr]

  • No [auth] verification errors (because no authenticated request ever arrives)
  • No AttributeError, no JWT decode failure

Fix Action

Fix / Workaround

Note: A version bisection (downgrade to 2.1.80 to confirm regression) has not been performed yet, but server-side and AS-side innocence are fully established by curl.

Code Example

There is no explicit error message on the Claude Code side; the failure is silent. Observations from each layer:

[Claude Code side]
- ~/.claude.json: no `accessToken` field for the MCP server entry after the OAuth flow appears to complete in the browser
- Windows Credential Manager: no Claude-related entries

[Browser side]
- After OTP entry, the page navigates to:
  http://localhost:8765/callback?code=aZkCV...&state=F0i2pqI...
- Browser displays: "Authentication Successful, You can close this window. Return to Claude Code."

[Cloudflare Access Authentication Log (Cloudflare dashboard)]
- allowed: true
- action: login
- connection: onetimepin
User authentication on the AS side succeeded

[MCP server side — stderr]
- No [auth] verification errors (because no authenticated request ever arrives)
- No AttributeError, no JWT decode failure

[MCP server side — access log]
- Repeated GET /.well-known/oauth-protected-resource/mcp (PRM fetches)
- GET /.well-known/oauth-authorization-server/mcp → 404 (expected, MCP spec fallback)
- GET /.well-known/openid-configuration/mcp → 404 (expected)
- GET /mcp/.well-known/openid-configuration → 404 (expected)
- No POST /mcp with Authorization header ever arrives

The gap is between "code received at redirect_uri" and "access_token persisted / Bearer attached to MCP requests".

Additional context: a Claude Code auto-update from 2.1.80 to 2.1.143 was observed during the same debugging session (no manual update was triggered). I have not yet downgraded to 2.1.80 to bisect, but the timing correlation with the auto-update is suggestive of a regression somewhere in this version range.

---

from mcp.server.fastmcp import FastMCP
   from mcp.server.auth.provider import AccessToken, TokenVerifier
   from mcp.server.auth.settings import AuthSettings
   from pydantic import AnyHttpUrl
   from jose import jwt
   from jose.exceptions import JWTError
   import httpx, time, os

   class CFTokenVerifier(TokenVerifier):
       # JWKS-based RS256 verification, issuer check, email allowlist
       # audience check disabled (Cloudflare aud = redirect_uri, non-standard)
       def __init__(self, team_domain, app_id, allowed_email):
           self._issuer = f"https://{team_domain}/cdn-cgi/access/sso/oidc/{app_id}"
           self._jwks_url = f"{self._issuer}/jwks"
           self._allowed_email = allowed_email
           self._jwks = None; self._fetched = 0

       async def _get_jwks(self):
           if not self._jwks or time.time() - self._fetched > 3600:
               async with httpx.AsyncClient(timeout=10) as c:
                   r = await c.get(self._jwks_url); r.raise_for_status()
                   self._jwks = r.json(); self._fetched = time.time()
           return self._jwks

       async def verify_token(self, token):
           try:
               jwks = await self._get_jwks()
               kid = jwt.get_unverified_header(token).get("kid")
               key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
               if not key: return None
               claims = jwt.decode(token, key, algorithms=["RS256"],
                                  issuer=self._issuer,
                                  options={"verify_aud": False})
               if claims.get("email") != self._allowed_email: return None
               return AccessToken(token=token, client_id=claims.get("aud"),
                                  scopes=["openid"], expires_at=claims.get("exp"))
           except (JWTError, Exception):
               return None

   verifier = CFTokenVerifier(os.environ["CF_TEAM_DOMAIN"],
                              os.environ["CF_OIDC_APP_ID"],
                              os.environ["MCP_ALLOWED_EMAIL"])

   mcp = FastMCP(
       "minimal-mcp",
       host="127.0.0.1", port=8000,
       token_verifier=verifier,
       auth=AuthSettings(
           issuer_url=AnyHttpUrl(verifier._issuer),
           resource_server_url=AnyHttpUrl(f"https://{os.environ['MCP_PUBLIC_HOST']}/mcp"),
           required_scopes=["openid"],
       ),
   )

   @mcp.tool()
   def ping(message: str = "hello") -> str:
       return f"pong: {message}"

   if __name__ == "__main__":
       mcp.run(transport="streamable-http")
RAW_BUFFERClick to expand / collapse

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • I am using the latest version of Claude Code

What's Wrong?

Claude Code 2.1.143 fails at the OAuth Token Exchange step when connecting to a remote MCP server backed by a non-DCR Authorization Server. In my setup the AS is Cloudflare Access for SaaS (OIDC), and the client_id/client_secret are supplied manually via claude mcp add --client-id ... --client-secret ....

The browser-based authorization flow completes successfully (user enters OTP, Cloudflare returns a valid code=... to the redirect_uri), but the subsequent Token Exchange POST to the AS's token endpoint either does not happen or its response is not persisted: ~/.claude.json never receives an accessToken for the MCP server, and the MCP server side never sees an authenticated POST /mcp request.

The server side and the Authorization Server side are both verified correct end-to-end by curl reproduction (see Steps to Reproduce). The Cloudflare token endpoint accepts the standard OAuth grant_type=authorization_code and returns 200 + access_token; the same access_token then drives a complete MCP session (initialize → notifications/initialized → tools/list → tools/call) with success.

This is distinct from issue #46140 and the related claude-ai-mcp issues (#46, #155, #215, #136), all of which describe Claude.ai web/Desktop failures with Claude Code (CLI) explicitly noted as working. The Claude Code CLI failing at Token Exchange against a non-DCR AS appears under-reported.

Note: A version bisection (downgrade to 2.1.80 to confirm regression) has not been performed yet, but server-side and AS-side innocence are fully established by curl.

What Should Happen?

After the browser completes the OAuth authorization (user enters OTP, Cloudflare issues an authorization code and redirects to redirect_uri at http://localhost:8765/callback?code=...), Claude Code should:

  1. POST the authorization code to the AS token endpoint with:
    • grant_type=authorization_code
    • code=<received code>
    • code_verifier=<PKCE verifier>
    • redirect_uri=http://localhost:8765/callback
    • client_id and client_secret (from claude mcp add ... arguments)
  2. Receive the access_token from the 200 response
  3. Persist the access_token to ~/.claude.json under the MCP server's auth entry (or platform credential store)
  4. Use the token as Authorization: Bearer <access_token> in subsequent POST /mcp requests, starting with initialize

This is exactly what manual curl reproduction achieves end-to-end (see Steps to Reproduce, step 11–12).

Error Messages/Logs

There is no explicit error message on the Claude Code side; the failure is silent. Observations from each layer:

[Claude Code side]
- ~/.claude.json: no `accessToken` field for the MCP server entry after the OAuth flow appears to complete in the browser
- Windows Credential Manager: no Claude-related entries

[Browser side]
- After OTP entry, the page navigates to:
  http://localhost:8765/callback?code=aZkCV...&state=F0i2pqI...
- Browser displays: "Authentication Successful, You can close this window. Return to Claude Code."

[Cloudflare Access Authentication Log (Cloudflare dashboard)]
- allowed: true
- action: login
- connection: onetimepin
  → User authentication on the AS side succeeded

[MCP server side — stderr]
- No [auth] verification errors (because no authenticated request ever arrives)
- No AttributeError, no JWT decode failure

[MCP server side — access log]
- Repeated GET /.well-known/oauth-protected-resource/mcp (PRM fetches)
- GET /.well-known/oauth-authorization-server/mcp → 404 (expected, MCP spec fallback)
- GET /.well-known/openid-configuration/mcp → 404 (expected)
- GET /mcp/.well-known/openid-configuration → 404 (expected)
- No POST /mcp with Authorization header ever arrives

The gap is between "code received at redirect_uri" and "access_token persisted / Bearer attached to MCP requests".

Additional context: a Claude Code auto-update from 2.1.80 to 2.1.143 was observed during the same debugging session (no manual update was triggered). I have not yet downgraded to 2.1.80 to bisect, but the timing correlation with the auto-update is suggestive of a regression somewhere in this version range.

Steps to Reproduce

Environment:

  • Claude Code: 2.1.143
  • OS: Windows 11
  • Shell: PowerShell
  • MCP transport: streamable-http
  • Authorization Server: Cloudflare Access for SaaS (OIDC, non-DCR, manual client_id/secret)
  • MCP server: Python, MCP SDK 1.27.x

Server-side one-time setup:

  1. Cloudflare Zero Trust dashboard → Access → Applications → Add application → SaaS application

  2. Record the Application UUID (= Client ID) and Client Secret from the dashboard.

  3. Implement a minimal MCP server with Python MCP SDK 1.27.x:

   from mcp.server.fastmcp import FastMCP
   from mcp.server.auth.provider import AccessToken, TokenVerifier
   from mcp.server.auth.settings import AuthSettings
   from pydantic import AnyHttpUrl
   from jose import jwt
   from jose.exceptions import JWTError
   import httpx, time, os

   class CFTokenVerifier(TokenVerifier):
       # JWKS-based RS256 verification, issuer check, email allowlist
       # audience check disabled (Cloudflare aud = redirect_uri, non-standard)
       def __init__(self, team_domain, app_id, allowed_email):
           self._issuer = f"https://{team_domain}/cdn-cgi/access/sso/oidc/{app_id}"
           self._jwks_url = f"{self._issuer}/jwks"
           self._allowed_email = allowed_email
           self._jwks = None; self._fetched = 0

       async def _get_jwks(self):
           if not self._jwks or time.time() - self._fetched > 3600:
               async with httpx.AsyncClient(timeout=10) as c:
                   r = await c.get(self._jwks_url); r.raise_for_status()
                   self._jwks = r.json(); self._fetched = time.time()
           return self._jwks

       async def verify_token(self, token):
           try:
               jwks = await self._get_jwks()
               kid = jwt.get_unverified_header(token).get("kid")
               key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
               if not key: return None
               claims = jwt.decode(token, key, algorithms=["RS256"],
                                  issuer=self._issuer,
                                  options={"verify_aud": False})
               if claims.get("email") != self._allowed_email: return None
               return AccessToken(token=token, client_id=claims.get("aud"),
                                  scopes=["openid"], expires_at=claims.get("exp"))
           except (JWTError, Exception):
               return None

   verifier = CFTokenVerifier(os.environ["CF_TEAM_DOMAIN"],
                              os.environ["CF_OIDC_APP_ID"],
                              os.environ["MCP_ALLOWED_EMAIL"])

   mcp = FastMCP(
       "minimal-mcp",
       host="127.0.0.1", port=8000,
       token_verifier=verifier,
       auth=AuthSettings(
           issuer_url=AnyHttpUrl(verifier._issuer),
           resource_server_url=AnyHttpUrl(f"https://{os.environ['MCP_PUBLIC_HOST']}/mcp"),
           required_scopes=["openid"],
       ),
   )

   @mcp.tool()
   def ping(message: str = "hello") -> str:
       return f"pong: {message}"

   if __name__ == "__main__":
       mcp.run(transport="streamable-http")
  1. Expose the MCP server via Cloudflare Tunnel at e.g. https://mcp.example.com/mcp

Claude Code reproduction:

  1. Run on Windows PowerShell: claude mcp add --callback-port 8765 --client-id <APP_ID> --client-secret <SECRET> myserver https://mcp.example.com/mcp

  2. The browser opens to Cloudflare's OTP authentication. Enter the OTP that arrives via email.

  3. Observe the browser redirect to http://localhost:8765/callback?code=...&state=... and the success message.

  4. Inspect ~/.claude.json: cat ~/.claude.json | grep -A 5 myserver → No accessToken field is present for the myserver entry

  5. Attempt to use the MCP server (e.g. via claude claude interactive mode and then asking it to call a tool from myserver): → fails because no Bearer token is present

Verification by curl (proves server side and AS side are correct):

  1. Generate PKCE pair and a state value (Python): python3 -c " import secrets, base64, hashlib v = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode() c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode() s = 'test-' + secrets.token_urlsafe(8) print('VERIFIER=', v); print('CHALLENGE=', c); print('STATE=', s) "

  2. Manually open the authorize URL in browser: https://<TEAM_DOMAIN>/cdn-cgi/access/sso/oidc/<APP_ID>/authorization ?response_type=code &client_id=<APP_ID> &redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback &scope=openid &state=<STATE> &code_challenge=<CHALLENGE> &code_challenge_method=S256

  3. Receive the code at a minimal local HTTP listener on :8765 (e.g. Python http.server).

  4. Exchange code for access_token (curl): curl -i -X POST https://<TEAM_DOMAIN>/cdn-cgi/access/sso/oidc/<APP_ID>/token
    -H "Content-Type: application/x-www-form-urlencoded"
    -d "grant_type=authorization_code"
    -d "code=<CODE>"
    -d "redirect_uri=http://localhost:8765/callback"
    -d "client_id=<APP_ID>"
    -d "client_secret=<SECRET>"
    -d "code_verifier=<VERIFIER>" → HTTP/2 200 {"access_token":"...","id_token":"...","expires_in":3600,"token_type":"Bearer"}

  5. Use the access_token to drive a complete MCP session:

    initialize

    curl -i -X POST https://mcp.example.com/mcp
    -H "Authorization: Bearer <access_token>"
    -H "Content-Type: application/json"
    -H "Accept: application/json, text/event-stream"
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' → 200 OK, response header includes Mcp-Session-Id: <SID>

    notifications/initialized

    curl -X POST https://mcp.example.com/mcp
    -H "Authorization: Bearer <access_token>"
    -H "Mcp-Session-Id: <SID>"
    -H "Content-Type: application/json"
    -H "Accept: application/json, text/event-stream"
    -d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' → 202 Accepted

    tools/call ping

    curl -X POST https://mcp.example.com/mcp
    -H "Authorization: Bearer <access_token>"
    -H "Mcp-Session-Id: <SID>"
    -H "Content-Type: application/json"
    -H "Accept: application/json, text/event-stream"
    -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ping","arguments":{"message":"hello from curl"}}}' → 200 OK data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"pong: hello from curl"}],"isError":false}}

This proves: (a) the Cloudflare token endpoint accepts standard grant_type=authorization_code and returns a valid token; (b) the MCP server correctly verifies the token and serves tools.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

No response

Claude Code Version

2.1.143

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

PowerShell

Additional Information

Related issues (all describe Claude.ai web / Claude Desktop failures, not Claude Code CLI):

  • anthropics/claude-code#46140 (CRITICAL)
  • anthropics/claude-ai-mcp#46, #136, #155, #215, #240

Note on the Cloudflare OIDC discovery quirk (informational, may not be relevant to root cause):

  • grant_types_supported is declared as ["refresh_tokens", "authorization_code_with_pkce"]. Despite this non-standard naming, the token endpoint accepts the standard grant_type=authorization_code (verified in step 13). If Claude Code is rejecting the AS based on a literal match against grant_types_supported, that would manifest as silent Token Exchange failure with no error visible to the user — which matches the observed symptom. This is speculative; suggesting the team consider this when investigating.

Suggested investigation steps for the Claude Code team:

  • Capture HTTP traffic at the Token Exchange step (the POST to the AS token endpoint) — is it being attempted at all?
  • If the POST is attempted, what does the response look like?
  • If the POST is not attempted, what is the trigger condition that blocks it (e.g. parsing of grant_types_supported)?
  • Confirm whether Claude Code 2.1.80 succeeds with the same setup (version bisection from reporter side has not been performed yet).

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix [BUG] OAuth Token Exchange not completed in 2.1.143 with non-DCR AS (Cloudflare Access for SaaS) — server side curl-verified, regression suspected from 2.1.80