Skip to content

withMcpAuth returns 403 instead of 401 for expired tokens that also lack scopes #181

Description

@anshusaurav

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions