[BUG] MCP OAuth: Claude doesn't auto-refresh access tokens, daily "Connection expired" despite valid refresh token

Status Open
Reported on v2.1.122
Maintainer reply None cached
Activity 14 comments · opened Jun 3, 2026

Preflight Checklist

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

What's Wrong?

Custom HTTP MCP server (Azure Function App) using Entra OAuth 2.0 + PKCE shows "Connection expired" daily in Claude.ai web and Claude Desktop, despite the refresh token being valid and the user's Entra session being within its 180-day sign-in frequency window.

When the user clicks Connect, Claude exchanges the stored refresh token at Entra's /token endpoint silently and reconnects in ~200ms — proving the refresh path itself works correctly. Claude just doesn't call it automatically when the access token expires; it falls through to the "needs reconnect" UI immediately.

This matches the diagnosis in closed issue #43789 by @nwoonet:

The encrypted token cache is populated and persisted across sessions, so refresh tokens are being stored. The client just isn't exercising the refresh endpoint when the access token expires — it falls straight through to the bootstrap auth tools.

Related closed/duplicate issues: #43789, #28262, #5706, #12447, #55055, #53803, #54742, #47219.

Evidence from Entra sign-in logs when user clicks Connect after seeing "Connection expired":

  • additionalDetails: "MFA requirement satisfied by claim in the token" — Entra's signature of a successful silent refresh-token exchange
  • status.errorCode: 0
  • conditionalAccessStatus: success
  • appliedConditionalAccessPolicies: [] (no challenge required)

This proves the refresh exchange itself succeeds instantly when invoked — Claude is just not invoking it without a manual user trigger.

Impact: every OAuth-based MCP connector in our org sidebar (custom MCPs + Anthropic-first-party Atlassian, Microsoft 365, Google Drive) shows "Connection expired" daily. Multi-step automations stall when access tokens expire mid-workflow. The only workaround is the community mcp-stdio local bridge, which requires per-laptop install.

Cross-platform — not Windows-specific. Confirmed by multiple colleagues at our org on both Windows 11 and macOS. Matches @nwoonet's observation in #43789: "Confirming this on macOS 26 / Claude Code 2.1.122. The issue is not Windows-specific."

What Should Happen?

When the access token expires (or returns 401 from the MCP server), Claude should:

  1. Detect the expiry
  2. POST to the IdP's /token endpoint with the cached refresh token
  3. Receive a new access token (no user interaction required)
  4. Retry the original MCP call transparently
  5. Surface no UI to the user

Only fall back to the "Connection expired / reconnect" UI when the refresh attempt itself fails (e.g. refresh token is also expired or revoked).

This is standard OAuth 2.0 client behavior (RFC 6749 §6) — auto-refresh is what makes long-running OAuth sessions usable. Most OAuth-aware HTTP clients (Microsoft.Identity.Web, MSAL, Google's auth libs, etc.) handle this automatically.

Error Messages/Logs

UI banner shown daily:
  "Connection has expired. You can reconnect to re-authenticate."

No actual error logs are produced by Claude — the failure is silent (Claude flips state to expired without logging a 401 from a refresh attempt).

Entra sign-in log entry observed when user clicks Connect to recover (proves refresh works when invoked):
  createdDateTime:                   2026-06-01T13:18:50Z
  status.errorCode:                  0
  status.additionalDetails:          "MFA requirement satisfied by claim in the token"
  conditionalAccessStatus:           success
  appliedConditionalAccessPolicies:  []

The "MFA satisfied by claim in token" detail is Entra's signature of a silent refresh-token exchange. This succeeded in ~200ms with no user prompt. The same exchange should happen automatically on access token expiry, before showing the "Connection expired" UI.

Steps to Reproduce

  1. Set up a custom HTTP MCP server that requires OAuth (Authorization Code + PKCE)

against any IdP that follows standard OAuth 2.0. We used Microsoft Entra,
but the issue is IdP-agnostic per other reports in #43789 (which mentions
Sentry, Atlassian, Superhuman Mail, Vercel, etc.)

  1. Add the MCP server as a custom connector in Claude.ai (web) and/or

Claude Desktop. Provide OAuth Client ID + Secret.

  1. Click "Connect". OAuth flow completes successfully. Connector shows

as "Connected". Claude.ai stores: access_token (~1h lifetime) +
refresh_token (typically days to months).

  1. Use the MCP server normally, invoke its tools from a chat. Works fine.
  1. Wait for the access_token to expire (~1 hour after step 3).
  1. Open Claude.ai / Claude Desktop. Observed: the connector shows

"Connection issue / Connection has expired. You can reconnect to
re-authenticate." Expected: silent background refresh; user sees
nothing.

  1. Click "Connect". Claude silently exchanges the cached refresh_token

at the IdP's /token endpoint and reconnects in ~200ms — no Microsoft
login window, no MFA prompt, no UI interaction.

This step proves the refresh_token is valid and the refresh exchange
works perfectly when manually triggered. The bug is that step 6
should not require step 7 — Claude should perform step 7 automatically
when the access_token expires.

  1. Use any MCP tool works again until the next ~1-hour expiry.
  2. Next morning, GOTO step 6.

Minimal repro setup is in the linked closed issues #43789, #55055, etc.
We can provide our specific Azure Function App OAuth metadata if helpful
for reproduction with an Entra-backed test app.

Claude Model

None

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.144 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

PowerShell

Additional Information

Workaround in use today

Daily 1-click reconnect. We extended our Entra Conditional Access "Sign-in frequency" policy to 180 days specifically scoped to the MCP app, which makes the reconnect silent (no Microsoft login window, no MFA prompt) — but the "Connection expired" UI in Claude still appears daily because it's not driven by actual token state.

Community workaround

mcp-stdio (https://github.com/shigechika/mcp-stdio) — a local stdio bridge that holds the refresh token client-side and refreshes transparently on 401. Confirms the behavior is fixable from the client side; the gap is in Claude itself.

OAuth refresh is the foundation of every modern long-lived API integration. Without it, every OAuth-based MCP feels broken to users and looks "in beta" forever. Fixing this single client-side bug probably resolves a large portion of the connector pain reported across #28262, #5706, #12447, #43789, #47219, #53803, #54742, #55055.

View original on GitHub ↗

14 Comments

SupremeTechnopriest · 4 months ago

🤦‍♂️ Just finished implementing oauth2 that I didn't need so customers could easily connect to our MCP with Claude desktop. Weeks of work to find that tokens don't refresh. What a joke... Why did you over complicate connecting an MCP when a simple header would suffice only to partially implement the spec? Please fix.

ThomasLononno · 3 months ago

Confirming this on Claude Cowork (Windows) as well — same daily "Connection expired" against a custom Entra ID (Azure AD) OAuth 2.0 + PKCE MCP server hosted on Azure App Service. Clicking Connect silently refreshes in ~200 ms, so the refresh token and Entra session are valid; the client just isn't calling the refresh endpoint when the access token expires. We run several of these connectors across a team, so the daily manual reconnect scales linearly with seat count — a proactive client-side refresh on 401/expiry would be a real reliability win for enterprise Entra deployments. Happy to share App Service / Entra sign-in logs if useful.

czietsman · 3 months ago

Independent repro with a few diagnostics not yet in the thread, in case they help isolate the root cause.

Setup: custom Streamable-HTTP MCP server, OAuth 2.1 auth-code + PKCE, public client (no secret), Microsoft Entra as IdP. Same symptom: drops at ~the access-token lifetime (~60–90 min); a manual "Connect" silently re-establishes in well under a second with no login/MFA prompt.

Added data points:

  1. **The client is requesting offline_access.** Captured the live /authorize request via browser devtools; scope is …/<resource-scope> offline_access openid. So this isn't the client failing to ask for a refresh token (re: the open question about whether the provider must grant offline_access — here it's requested and granted).
  1. The refresh exchange works independently. Separate from the manual-reconnect evidence already posted: a device-code flow against the same app requesting offline_access returns a refresh_token, and a grant_type=refresh_token POST to the Entra /token endpoint mints a fresh access token (with a rotated refresh token). Entra issues and honors the refresh token fine; the client just never performs this exchange automatically on expiry/401.
  1. Possible variable: the RFC 8707 resource parameter. Our custom connector advertises an RFC 9728 protected-resource resource, so the client includes &resource=<https /mcp URL> in /authorize. By contrast, a first-party Entra-backed connector in the same workspace (Microsoft 365) issues /authorize with scope=api://…/.default offline_access openid email and no resource parameter, over the same claude.ai/api/mcp/auth_callback redirect, and does not show the daily drop for us. I can't observe whether the client sends resource on the refresh grant (server-side), but RFC 8707 resource handling on refresh is easy to get wrong, so it may be worth checking whether the resource indicator affects the auto-refresh path. (This thread lists M365 as affected for others, so this may be a per-config difference rather than strictly first-party-vs-custom.)

Net: request side and IdP side both check out — the only broken link is the client not calling /token on expiry/401, matching this issue's ask. Happy to share HAR/log snippets.

pavele · 3 months ago

+1 This is a serious issue. Our team lost trust in the connectors because they're not stable, and it is not clear when something is stale or not working.

Stache73 · 2 months ago

Confirming this also happens in the Claude Code VS Code extension specifically (Windows), with one addition: in my case even the manual-reconnect workaround this issue's own "workaround in use today" section relies on didn't reliably recover it.

Setup: A claude.ai account-level MCP connector (OAuth-based, not a project-scoped .mcp.json server), used from an automated/agentic Claude Code session working against it over several hours.

Symptom: /mcp showed the connector as "connected" throughout, but every tool call against it returned MCP server "<name>" session expired — identical error text on every attempt, across multiple different tools on that same server. A useful additional data point: a lightweight resource-listing call (ListMcpResourcesTool) against the same server succeeded cleanly at the same time tool invocation was failing — so the connector was reachable, and this wasn't a total disconnect, only the tool-invocation/authorization path specifically.

Recovery attempts, all before this comment:

  1. Simple retry — failed identically.
  2. /mcp reconnect all from the interactive chat panel — did not run at all: MCP controls aren't available right now — the terminal is still starting up or is showing another view. (Session was fully up and otherwise responsive; this wasn't actually starting up.)
  3. Full VS Code window reload ("Developer: Reload Window") — did not fix it. /mcp afterward still showed the same server as not connected (4 of 9 configured servers not connected, consistent before and after reload).

So in this environment, the expected "just click reconnect" workaround wasn't available either — not just inconvenient, but actually blocked by a separate UI-state issue on top of the underlying token-refresh gap this issue already describes. Happy to provide more detail (Claude Code version, timestamps) if useful — didn't want to include anything specific to the private server/repo involved.

raphaellimaes · 2 months ago

Confirming this against the Databricks MCP connector — and I want to add evidence that rules out server-side misconfiguration, since that's the usual first suspect.

Environment

  • Connector: Databricks MCP (OAuth 2.0 custom app integration, account-level)
  • Clients affected: Claude web and Desktop
  • Scale: 50+ users in our organization, hit daily

Databricks-side OAuth policy (verified via databricks account custom-app-integration get)

"token_access_policy": {
  "access_token_ttl_in_minutes": 1440,
  "refresh_token_ttl_in_minutes": 43200,
  "absolute_session_lifetime_in_minutes": 129600,
  "enable_single_use_refresh_tokens": false
}
darshan-panchal1 · 1 month ago

I'm building a self-hostable MCP auth proxy that does silent refresh for any client, would you use it?

tejuguasu · 1 month ago
I'm building a self-hostable MCP auth proxy that does silent refresh for any client, would you use it?

The proxy would need to see the refresh token; that's unsafe by design.

darshan-panchal1 · 1 month ago

Fair concern, but that's the tradeoff of self-hosting vs a hosted middleman. the token never leaves your infra, you control the encryption/storage, and it's the same trust boundary as any reverse proxy or secrets manager you already run. Not saying it's risk-free (proxy becomes a bigger target, needs proper encryption at rest + per-user scoping, no shared tokens), but "some service holds the refresh token" is unavoidable if you want silent refresh at all, question is whether that service is yours or a vendor's.

raspy8766 · 1 month ago

I think I found a decent and reliable workaround. Here's Claude's synthesis of it below:

claude mcp login "<connector name>" recovers a lapsed connector in one command — no Settings disconnect/re-add. Its help covers claude.ai connectors explicitly, and it took effect in an already-running session for me. Better, put it in a skill and stop thinking about it. A ` !… line executes *before* the skill body reaches the model, so invoking the skill runs the login deterministically — and a description naming the failure strings lets an agent load it itself instead of reporting the connector as down: ``markdown --- name: aws-login description: Re-run the OAuth consent for the claude.ai AWS MCP connector. Use it whenever an AWS connector tool returns "This connector requires authentication", "The user's connection to this connector was invalidated", or any 401. user-invocable: true --- !env -u CLAUDE_CODE_ENTRYPOINT claude mcp login "claude.ai AWS MCP" 2>&1 | tail -20 `` /aws-login is then the entire recovery. Swap the connector name for any other. **Why env -u CLAUDE_CODE_ENTRYPOINT.** Claude Code exports that variable to child processes, and it lands as product_surface on the generated auth URL. Under the inherited value, a login over a still-valid grant short-circuits to .../settings/mcp/auth_done and never reaches the provider — fine for recovery, useless when you need to *force* a fresh consent. That matters for connectors that bind to a choice made at consent time. The AWS one asks which of your active AWS sessions to use, so the connector runs as whichever IAM role and account you picked, and re-consenting is the only way to change it. Dropping the variable (claude-desktop → sdk-cli) renders the picker even over a live grant. Two runs rebound the connector to a different role, confirmed with sts get-caller-identity` back through the connector.
SarathBug · 1 month ago

Environment

Claude Code version: 2.1.259
MCP server type: remote, "type": "http" with OAuth (not a locally-spawned command/args server)
Same MCP server (same OAuth backend/URL) also used via Cursor — no expiry issues there, isolating this to Claude Code's client behavior specifically, not a server-side/short TTL issue.
Symptom

Mid-session, MCP tool calls suddenly fail with MCP server "<name>" requires re-authorization (token expired), requiring manual /mcp re-auth.
Observed within as little as ~2 hours in a single long-running session.
Root cause evidence found

Inspected ~/.claude/.credentials.json for the affected server's mcpOAuth entry.
The stored refreshToken is present alongside the accessToken — so the client has what it needs to refresh silently, but doesn't appear to do so proactively (only reacts after a call fails).
More specifically: the entry's expiresAt timestamp was earlier than the credentials file's own last-write (mtime) timestamp — i.e., the token was persisted to disk already ~3.5 minutes past its own expiry. This suggests the client may be mishandling/miscomputing expiresAt at the time it persists the OAuth response, not just skipping a background refresh.
Repro steps for others to check

Configure a remote "type": "http" MCP server with OAuth.
After using it for a while, when a call fails with "token expired," before re-authorizing, run: compare stat -c '%y' ~/.claude/.credentials.json (or equivalent) against the expiresAt field for that server's entry in the same file.
Compare against another MCP client using the identical server/OAuth backend over a similar or longer session — no equivalent expiry behavior observed there.
No client-side workaround exists

settings.json/settings.local.json only expose permission allowlists, no MCP-OAuth-refresh config.
Remote "type": "http" MCP entries have no headersHelper-style hook for a user-supplied refresh script (that's only relevant for locally-spawned command-based servers).

emmaai · 1 month ago

Correcting my earlier comment: I said the client never attempts a refresh. It does. Token requests go straight to the IdP and never touch the resource server, so from the gateway a refused refresh looks identical to no refresh. That was my mistake.

Two things were happening. Ours was that Entra refused the refresh with AADSTS65001, because the app was registered as its own OAuth client and no delegated permission grant existed for it. If you're on Entra, worth checking oauth2PermissionGrants for the client/resource pair.

The client-side one, which is the actual bug: a refused refresh is terminal. From a server where we run the authorization server ourselves:

06:38  POST /oauth/token  200
+23h   POST /oauth/token  400   refresh token expired
after  nothing, for 2+ days

No token or authorize requests after that, while the MCP server kept getting calls with no Authorization header. Same after the Entra rejection. A 4xx from the token endpoint should trigger a new authorization request.

SupremeTechnopriest · 1 month ago

Clanker slop comments on this issue 🤦‍♂️

pluckey-origami · 20 days ago
+1, another data point w/ auth server logs

client: claude desktop (cowork, windows)
connector: claude.ai account-level, remote http mcp
auth server: cognito, public client, auth code + pkce
access token ttl: 24h
refresh token ttl: 90d
symptom: "can't reach it, connect" after idle; clicking connect fixes it instantly

token endpoint log (cloudtrail) for the affected client:
  day 1 ~15:46   refresh_token        200
  +17h           nothing, no refresh attempt, no 400
  day 2 08:44    authorization_code   200  (user clicked connect)

takeaway: client isn't getting refused, it never tries to refresh