Bug
When withMcpAuth is configured with requiredScopes, an expired token that also lacks a required scope receives 403 insufficient_scope instead of 401 invalid_token.
Per RFC 6750 §3.1, an expired token is invalid_token regardless of what other checks fail. The distinction matters because:
401 invalid_token tells the client its token is no longer valid → the client should refresh or re-authenticate
403 insufficient_scope tells the client it has the wrong permissions → the client will not attempt a refresh and may surface a misleading "insufficient permissions" error to the user
Reproduction
import { withMcpAuth } from "mcp-handler";
const handler = withMcpAuth(
() => new Response("ok"),
(_req, bearer) => ({
token: bearer!,
clientId: "c1",
scopes: ["read"], // ← missing "admin"
expiresAt: Math.floor(Date.now() / 1000) - 60, // ← expired
}),
{ required: true, requiredScopes: ["admin"] },
);
const res = await handler(
new Request("https://example.com/mcp", {
headers: { Authorization: "Bearer tok" },
}),
);
console.log(res.status);
// Actual: 403 (insufficient_scope)
// Expected: 401 (invalid_token)
Cause
In src/auth/auth-wrapper.ts, the scope check runs before the expiry check. An expired-and-unscoped token hits the scope gate first and never reaches the expiry gate.
Fix
Move the expiry check above the scope check so that expired tokens are always rejected as invalid_token (401) before scopes are evaluated.
Bug
When
withMcpAuthis configured withrequiredScopes, an expired token that also lacks a required scope receives403 insufficient_scopeinstead of401 invalid_token.Per RFC 6750 §3.1, an expired token is
invalid_tokenregardless of what other checks fail. The distinction matters because:401 invalid_tokentells the client its token is no longer valid → the client should refresh or re-authenticate403 insufficient_scopetells the client it has the wrong permissions → the client will not attempt a refresh and may surface a misleading "insufficient permissions" error to the userReproduction
Cause
In
src/auth/auth-wrapper.ts, the scope check runs before the expiry check. An expired-and-unscoped token hits the scope gate first and never reaches the expiry gate.Fix
Move the expiry check above the scope check so that expired tokens are always rejected as
invalid_token(401) before scopes are evaluated.