[BUG] MCP OAuth tokens without expires_in and refresh_token silently expires
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?
When an MCP server's OAuth token exchange endpoint /oauth/token returns only an access_token without expires_in or refresh_token (e.g. GitHub OAuth tokens), Claude Code silently expires the credential after some time (more than 30 minutes but less than 24 hours) and requires re-authentication.
After the token expires, Claude Code attempts the MCP initialize request without the Authorization header, which fails. The user must then manually re-authenticate via /mcp.
The token response from the OAuth server looks like this:
{
"access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"scope": "scopes",
"token_type": "bearer"
}
This is a valid OAuth 2.0 response — expires_in and refresh_token are optional per the spec. The access token itself is long-lived (does not expire on the server side), yet Claude Code stops using it after a certain period.
Two distinct issues here:
- Claude Code appears to impose an internal TTL on tokens that have no
expires_in, causing valid long-lived tokens to be discarded prematurely. - After the token is discarded, the MCP init request is sent without the auth header instead of prompting the user to re-authenticate before retrying.
What Should Happen?
- If the OAuth token response does not include
expires_in, Claude Code should treat the token as non-expiring and continue using it until the server rejects it with a401. - If the token is rejected with
401, Claude Code should prompt the user to re-authenticate before retrying the MCP init request, rather than sending an unauthenticated request.
Error Messages/Logs
Steps to Reproduce
- Configure an MCP server that uses OAuth and does not have refresh token or expiration date for token (e.g. a GitHub-based MCP server)
- Add MPC to claude code with
claude mcp add --transport http local-mcp http://localhost:8081/mcp - Open Claude Code and authenticate via the OAuth flow — the server returns a token without
expires_inorrefresh_token - Use Claude Code normally with MCP tools working
- Close cCaude Code
- Reopen after some period (observed: between 30 min and 24 hours)
- Navigate to /mcp and check that mcp shows "error" in authentication
- Observe that the MCP init request is sent without the Authorization header
Claude Model
None
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.44
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
iTerm2
Additional Information
_No response_
17 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Related to https://github.com/anthropics/claude-code/issues/9017 - However we believe it is not just documentation issue, but actual usability bug
Adding another reproduction case for this bug, specifically via Claude Cowork (not Claude Code CLI).
Setup: Todoist MCP connector authenticated via OAuth in Claude Cowork, used by a scheduled task that fires daily at 9am and 5pm.
What happens: The tool call (find-tasks-by-date) returns HTTP 401: Unauthorized. A second retry also fails. Manually re-authenticating via Settings > Connectors fixes it immediately.
Confirmed with Todoist: Todoist's OAuth tokens do not expire server-side, and their hosted MCP server works correctly with other clients (ChatGPT, etc.). The 401 is caused by Claude sending an invalid or missing token, not by server-side expiry.
Community reports: Multiple users hitting the same issue with Todoist + Claude, particularly with scheduled/automated tasks: https://www.reddit.com/r/todoist/comments/1rqdk6h/is_it_normal_that_the_todoist_mcp_connector_in/
This is consistent with the two issues described in this bug — Claude imposing an internal TTL on tokens without expires_in, and then retrying without the Authorization header rather than prompting re-auth.
This also occurs with Atlassian/Slack cloud MCP servers . Replicated using Claude Code CLI. Using the
/mcpskill shows "error" in authentication. Restarting the claude session AFTER /mcp auths correctly.Unfortunately, this is a known limitation — Claude Code doesn't implement automatic OAuth token refresh properly for GitHub, Todoist (or any provider, really). Ironically, Todoist and Github issue immortal OAuth tokens, but Claude still messes them up somehow.
I built Bindify to solve exactly this. It's an MCP auth proxy that sits between Claude Code and GitHub, Todoist, etc. You authenticate once through Bindify, get a secret URL, and paste it into Claude Code (or Claude.ai) as a remote MCP server. Bindify handles the OAuth token refresh server-side. If you still run into issues, you can fall back to API token authentication, even with Claude.ai/mobile via Bindify's secure URLs.
Setup guide: https://bindify.dev/fix/claude-todoist-mcp-expired — use code CLAUDEAUTHFIX to try it free for 2 months (limited to 10 people).
Disclaimer: I'm the developer behind Bindify. Feel free to reach out with questions.
Considering that MCP is not encrypted, I would strongly discourage the use of such proxy. Not speaking of the authorization token handling. This is a use case for open source not for a paid service sorry.
Thanks @OLH21 — this is really valuable feedback. You’re right that trust is a prerequisite for a product like this, and you’re right that the proxy can see messages passing between the chat client and the MCP server. I take that seriously.
I’m planning to open source the core proxy code this week so people can verify what it does (and doesn’t do) with their data, and so anyone who prefers to self-host can do so. The hosted service will stick around for people who’d rather not manage their own infrastructure — not everyone using Claude with MCP connections is comfortable deploying their own services. The proxy doesn’t log request or response payloads — it only stores the metadata needed for token management.
I appreciate the pushback, genuinely. It’s making the product better.
Same thing happens on Claude.ai/Claude Desktop. Makes MCPs in this situation basically unusable through Claude. I can't realistically be expected to manually re-authentify every couple of hours for a bunch of services. No offense to your offering @dylancwood, but one would think we would not have to pay for a third-party proxy just to fix something that should work correctly by the product we pay for in the first place.
@js-beaulieu : I agree for the most part. It should “just work”, and needing to reauthenticate all the time does make the MCP integrations almost useless. The open source claude code project just had some fixes land recently for auth token refresh, so that should help. My offering is just to help bridge the gap while Claude and other chat clients work through all the edge cases of managing OAuth on the client side. If you use the coupon code I provided for bindify.dev, you’ll get two months for free, and you don’t need to enter a credit card. A couple of people have signed up using that code already.
Confirmed a concrete real-world compatibility case for this.
I opened a corresponding Todoist MCP issue here:
A few details I was able to verify live:
https://ai.todoist.net/mcpis the hosted MCP endpoint and isPOST-only.https://ai.todoist.net/.well-known/oauth-protected-resourcecurrently returns:https://todoist.com/.well-known/oauth-authorization-serveradvertises:authorization_endpoint:https://todoist.com/oauth/authorizetoken_endpoint:https://todoist.com/oauth/access_tokenI did not complete a successful OAuth exchange against the hosted service, so I did not directly inspect the returned token payload from the MCP flow itself.
However, Todoist's published OAuth docs still show a successful token exchange response containing only:
Source:
So Todoist appears to be a plausible trigger for the behavior described in this issue: a valid OAuth token response without
expires_in/refresh_token, followed by client-side local expiry and eventual unauthenticated MCP init.From my perspective:
expires_inBut this issue still looks like the correct root bug to fix on the Claude side.
Unfortunately, in the case of Todoist, we already provide an expiry of 10 years, so I'm not sure if that is the cause.
I agree with @scottlovegrove . I think it's much more likely that Claude (code) receives the access_token with no
expires_indate, then applies it's own defaultexpires_invalue internally. When that time is up, Claude can't refresh, so it treats it as an auth failure. I think that this behavior is typical of OAuth clients, and I ran into it when I used Claude Code to build Bindify.@scottlovegrove : could Todoist OAuth response include a 10-year
expires_invalue to ensure that clients do not misunderstand?As I said, we already do this, have done since November as our first stab at trying to resolve this issue. I even verified it yesterday, just to make sure that nothing had regressed, but it's still there.
If you want to confirm this for yourself, you can prompt your agent to dynamically create a client with Todoist (details are in the
https://todoist.com/.well-known/oauth-authorization-serverfile), and ask it to guide you through the OAuth process and it will then be able to confirm what we return with the token.OAuth Token Exchange Verification (2026-03-20)
Completed a full OAuth flow against Todoist's authorization server to verify
expires_inbehavior. Here's what I found.Setup
POST https://todoist.com/oauth/register127.0.0.1to capture the codePOST https://todoist.com/oauth/access_tokenToken Response (redacted)
expires_inis present: 315,360,000 seconds = ~10 years. @scottlovegrove's claim is confirmed.What's missing from the response
| Field | Present | Notes |
|-------|---------|-------|
|
access_token| ✅ | ||
token_type| ✅ |Bearer||
expires_in| ✅ | 315,360,000s (~10 years) ||
refresh_token| ❌ | Not provided ||
scope| ❌ | Not echoed back (recommended by RFC 6749 §5.1) |The authorization server metadata also does not advertise
refresh_tokenas a supported grant type — onlyauthorization_code.Implications for this bug
Since
expires_inis present and set to ~10 years, the root cause is not a missing expiry field. The bug is more likely:expires_inand applies its own short internal TTL. When that TTL expires and norefresh_tokenis available, it discards the token instead of continuing to use it.Authorizationheader (per the original issue description) rather than prompting re-auth.The absence of
refresh_tokenis by design (tokens are ~immortal), but if Claude's OAuth client treats "no refresh_token + internal TTL expired" as "auth failed, discard token" rather than "token is still valid, keep using it" — that would explain the behavior.Separate issue: Revocation endpoint returns 401
While cleaning up, I attempted to revoke the test token via the endpoint advertised in Todoist's authorization server metadata (
revocation_endpoint: https://todoist.com/api/v1/revoke):Also tried with
Authorization: Bearer <token>header — same 401. The legacy endpoint (/sync/v9/access_tokens/revoke) returns410 Gone. This may be a separate Todoist-side issue — flagging for @scottlovegrove. The endpoint is referenced in their OAuth authorization server metadata.Thanks @ibarrajo. I confirmed as well. I think that my initial implementation was looking for a
refresh_tokenfirst, and if it wasn't there, it didn't even check forexpires_in. Possible that Claude is doing the same, but that's on them.BTW: I've open-sourced the src for the Bindify MCP Proxy here: https://github.com/dylancwood/bindify-proxy. Maybe it'll help someone :-)
Over 200 MCP servers are listed by Anthropic and all of them have this authentication issue?
@DenisPalnitsky The gap is Claude Code expecting
expires_in/refresh_tokenin the OAuth response, but GitHub/Todoist don't provide them. Workaround: wrap the access token in a middleware that adds explicit expiry metadata before passing it to the session. The token itself is valid; the metadata is what's missing.