DEV Community

Cover image for FastMCP and Github Auth Apps token expiry issue
Scott Raisbeck
Scott Raisbeck

Posted on

FastMCP and Github Auth Apps token expiry issue

Note: The below is AI generated, but i figured it is worth sharing in the hope that it helps others avoid the pain :)

My tokens kept dying. Not expiring - dying. Mid-session, no warning, just gone.

I'm building Forgetful, an MCP server that gives AI agents persistent memory. It uses GitHub OAuth for authentication, proxied through FastMCP. And for three days, every MCP client I connected - Claude Code, Gemini CLI, VS Code - would authenticate fine, work for 15-30 minutes, then fail with invalid_token.

I burned a lot of time chasing the wrong problems before finding the actual cause. This is that story.

The symptoms

Every client exhibited identical behaviour:

  • Fresh OAuth flow → works
  • Use tools for a while → works
  • Come back 20 minutes later → invalid_token
  • Re-authenticate → works again
  • Repeat

The consistency across different clients suggested a server-side issue. But what?

Dead end #1: Storage persistence

First thought: maybe tokens aren't persisting across container restarts?

I mounted a Docker volume for FastMCP's OAuth storage and cracked open the SQLite cache:

docker exec forgetful sqlite3 /root/.local/share/fastmcp/oauth-proxy/cache.db \
  "SELECT key, datetime(expires_at, 'unixepoch') FROM cache;"
Enter fullscreen mode Exit fullscreen mode

Everything was there. JTI mappings, upstream tokens, client registrations - all with sensible expiry times. Storage wasn't the problem.

Dead end #2: JWT signature issues

Maybe the JWT signing key was somehow getting rotated or mismatched?

FastMCP derives its signing key using PBKDF2:

kdf = PBKDF2HMAC(
    algorithm=hashes.SHA256(),
    length=32,
    salt=b"fastmcp-jwt-signing-key",
    iterations=1000000,
)
key = kdf.derive(secret.encode())
Enter fullscreen mode Exit fullscreen mode

I extracted a failing token, derived the key myself, verified the signature. It matched. The JWT was valid.

So the JWT was fine but authentication was failing. What gives?

The breakthrough: Debug logging

I enabled debug logging and watched the auth flow:

Token verified successfully for subject=None  ← JWT valid
GitHub token verification failed: 401 - "Bad credentials"  ← Upstream rejection
Enter fullscreen mode Exit fullscreen mode

There it was. FastMCP uses a two-tier system: it validates its own JWT, extracts a JTI (JWT ID), looks up the corresponding GitHub token it stored server-side, then validates that token with GitHub's API.

My JWT was fine. But when FastMCP tried to verify the upstream GitHub token, GitHub said "Bad credentials."

GitHub was invalidating tokens that FastMCP had cached. But why?

The smoking gun

GitHub has a security log. Settings → Security → Security log. I filtered for OAuth events and found this:

{
  "action": "oauth_access.destroy",
  "explanation": "max_for_app",
  "created_at": "2025-12-19T..."
}
Enter fullscreen mode Exit fullscreen mode

max_for_app. That's a new one.

Turns out GitHub OAuth Apps have a hard limit of 10 active tokens per user per application. When you create an 11th token, GitHub automatically revokes the oldest one.

I was using Claude Code, Gemini CLI, and VS Code. Each reconnection creates a new token. Three clients, multiple sessions, and I'd blown past 10 tokens without realising it. Every new authentication was killing an older client's token.

GitHub App vs OAuth App

The fix is switching from a GitHub OAuth App to a GitHub App. Yes, those are different things with confusingly similar names.

OAuth App GitHub App
Client ID prefix Ov... Iv...
Token limit per user 10 None
Uses OAuth scopes Yes No (uses app permissions)
Refresh tokens Optional If expiration enabled

GitHub Apps don't have the per-user token limit. You can have as many simultaneous clients as you want.

The implementation

Creating a GitHub App: Settings → Developer settings → GitHub Apps → New GitHub App

Key settings:

  • Callback URL: your OAuth callback endpoint
  • Webhook: disabled (not needed for pure OAuth)
  • Permissions: Account permissions → Email addresses → Read-only
  • Installation: "Any account"

Then update your FastMCP config:

FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID=Iv23li...  # Note the Iv prefix
FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET=your-secret-here
FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES=user
Enter fullscreen mode Exit fullscreen mode

The scopes gotcha

My first attempt failed with:

GitHub token missing required scopes. Has 1, needs 2
Enter fullscreen mode Exit fullscreen mode

I had configured REQUIRED_SCOPES=user:email,read:user. But GitHub Apps don't use OAuth scopes - they use app-level permissions configured in the GitHub UI. The X-OAuth-Scopes header comes back empty.

FastMCP handles this by defaulting to ["user"] when it sees empty scopes. So if you set REQUIRED_SCOPES=user, it matches that default and validation passes.

This is the kind of thing that only makes sense after you've read the FastMCP source code.

Verification

After the switch:

  • Claude Code: authenticated, stayed authenticated
  • Gemini CLI: authenticated, same user identity (external_id preserved)
  • Both running simultaneously: no token revocation
  • Client restart: reconnected without re-authentication

Three days of debugging, fixed by changing Ov to Iv and adjusting one config line.

What I learned

GitHub's security log is gold. The max_for_app explanation was right there - I just didn't know to look for it until I'd ruled out everything else.

Debug logging at every layer. The two-tier token system meant I needed to see both JWT validation AND upstream token validation. Without debug logging, I only saw "auth failed" with no indication of where.

OAuth Apps and GitHub Apps are genuinely different things. Same OAuth endpoints, different behaviour. The naming is unfortunate.

Token limits aren't always documented where you'd expect. I never found this limit in GitHub's main OAuth documentation. It's buried in rate limiting pages and security FAQs.


If you're building anything that uses GitHub OAuth with multiple clients per user - MCP servers, CLI tools, IDE extensions - consider starting with a GitHub App instead of an OAuth App. You'll skip this particular rabbit hole entirely.

If you're curious about Forgetful itself - the MCP server that led me down this rabbit hole - it's at github.com/scottrbk/forgetful.

Top comments (0)