Skip to content

OAuth verifyAccessToken returns 500 instead of 401 on invalid bearer token (v0.33.0) #935

@xaviroblessarries

Description

@xaviroblessarries

Bug

verifyAccessToken in src/core/oauth-provider.ts throws plain Error instances when a bearer token is invalid or expired. The MCP SDK's requireBearerAuth middleware (@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js) classifies thrown errors:

  • InvalidTokenError → HTTP 401 + WWW-Authenticate (per RFC 6749 / RFC 6750)
  • InsufficientScopeError → HTTP 403
  • ServerError → HTTP 500
  • Anything else (plain Error) → HTTP 500 with server_error

Because gbrain throws plain Error, bearer-token validation failures return HTTP 500 server_error instead of the canonical RFC 6750 401 invalid_token.

Reproducer

# Server v0.33.0 running (postgres engine, OAuth clients registered)
curl -X POST https://your-gbrain/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer invalid_token" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# Actual:
# {"error":"server_error","error_description":"Internal Server Error"}
# HTTP/2 500

# Expected (per RFC 6750 §3.1):
# {"error":"invalid_token","error_description":"Invalid token"}
# HTTP/2 401
# WWW-Authenticate: Bearer error="invalid_token", error_description="Invalid token"

Verified on both local (macOS) and remote (Railway) v0.33.0 deployments.

Root cause

src/core/oauth-provider.ts:

  • Line ~423: throw new Error('Token expired');
  • Line ~458: throw new Error('Invalid token');

Both inside verifyAccessToken. SDK bearerAuth.js:53-69 catch block routes plain Error to the else branch:

else {
  const serverError = new ServerError('Internal Server Error');
  res.status(500).json(serverError.toResponseObject());
}

Suggested fix

 import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
+import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
 import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts';

   ...
   const expiresAt = coerceTimestamp(row.expires_at);
   if (expiresAt === undefined || expiresAt < now) {
-    throw new Error('Token expired');
+    throw new InvalidTokenError('Token expired');
   }
   ...
-  throw new Error('Invalid token');
+  throw new InvalidTokenError('Invalid token');

Caveat: I tried this patch locally. It typechecks (bun run typecheck passes) and works perfectly when running via bun run src/cli.ts serve --http. But bun build --compile then fails at runtime — the binary exits silently. My hypothesis is that the deep import path @modelcontextprotocol/sdk/server/auth/errors.js doesn't bundle correctly with bun build --compile (the SDK's exports map uses a ./* glob fallback for this path rather than an explicit entry). The interpreted path is fine.

Possible workarounds for the build issue:

  1. Re-export InvalidTokenError from a path that IS explicit in the SDK's exports map.
  2. Define a local class that extends the SDK's OAuthError with errorCode = 'invalid_token' so instanceof InvalidTokenError still matches via the prototype chain (or instanceof OAuthError in the SDK side).
  3. Lazy await import() inside verifyAccessToken.

Impact

  • Security posture: clients receive misleading 500 server_error on invalid tokens (suggests server bug) instead of clean 401 invalid_token (which clients should auto-refresh on).
  • Spec compliance: RFC 6750 §3.1 requires 401 + WWW-Authenticate for invalid bearer tokens.
  • Client behavior: well-behaved OAuth clients (claude.ai web app's token-refresh logic) interpret 500 as transient and retry instead of refreshing the token.

Doctrine references

  • SECURITY.md — OAuth 2.1 compliance section
  • docs/mcp/DEPLOY.md — DEPLOY troubleshooting cites "missing_auth" and "invalid_token" as canonical client-facing error codes

Notes

  • Same pattern may apply to refresh-token validation throws (lines ~367, ~374) but those go through mcpAuthRouter /token endpoint (RFC 6749 §5.2 → HTTP 400 invalid_grant), not bearerAuth. Out of scope for this fix.
  • Found during a forensic audit of a Railway-deployed gbrain serve --http instance integrated with claude.ai web — happy to share the audit notes if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions