Skip to content

[v2] requireBearerAuth: drop error="invalid_token" when no credentials were sent and resource_metadata is set (RFC 6750 §3.1) #3124

Open

Description

@abubakar2906

What happened?

When a request has no Authorization header, requireBearerAuth answers:

WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="a:read", resource_metadata="https://mcp.example/.well-known/oauth-protected-resource/mcp"

RFC 6750 §3.1 says that when a request has no authentication information, the server SHOULD NOT include an error code.

This was tried in #785 (v1) and closed. The blocker there was §3: a Bearer challenge MUST carry at least one auth-param, so removing the error code meant adding a realm, which didn't fit.

That's different for MCP servers now. resource_metadata (RFC 9728) is the param MCP clients need to start sign-in, and the SDK already adds it when resourceMetadataUrl is set (and scope when requiredScopes is set).

Why it matters: the first request from every MCP client has no token, so this is the challenge every client sees first. invalid_token says "your token went bad" to a client that hasn't signed in yet. We ran into this building the MCP server for Bachs (a payments API), and write the challenge ourselves for now.

Would you take a PR for this? It's a small change plus tests in packages/server (the Express adapter shares bearerAuthChallengeResponse, so it would follow), and a v1.x backport if you want one.

What did you expect?

Drop the error code only when another param remains:

  • no credentials, resource_metadata or scope present → Bearer scope="a:read", resource_metadata="…"
  • no credentials, no other params → unchanged, so the challenge is never a bare "Bearer"
  • a token was sent but is bad → unchanged

Code to reproduce

import { requireBearerAuth } from '@modelcontextprotocol/server';

const gate = requireBearerAuth({
  verifier: { verifyAccessToken: async () => { throw new Error('unused'); } },
  requiredScopes: ['a:read'],
  resourceMetadataUrl: 'https://mcp.example/.well-known/oauth-protected-resource/mcp',
});
const res = await gate(new Request('https://mcp.example/mcp', { method: 'POST' }));
console.log(res.status, res.headers.get('www-authenticate'));

SDK version

@modelcontextprotocol/server 2.4.0

Area

Auth

Activity

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Oct 29, 2026
  2. Ben-Home commented on Oct 30, 2026

    @Ben-Home

    Checked the citation against RFC 6750 section 3.1, last paragraph: "If the request lacks any authentication information (e.g., the client was unaware that authentication is necessary or attempted using an unsupported authentication method), the resource server SHOULD NOT include an error code or other error information." So this is a SHOULD-level violation, and your reading of the old blocker is correct.

    The fix has one seam, which makes it smaller than it looks. Two places produce that header today:

    • packages/server/src/server/middleware/bearerAuth.ts:152 throws OAuthError(InvalidToken, 'Missing Authorization header') when the header is absent
    • bearerAuthChallengeResponse (same file, line 203) switches on error.code and calls buildWwwAuthenticateHeader(error.code, ...), and that builder unconditionally opens the string with Bearer error="..." (line 121)

    Missing credentials is not an invalid token. It is the absence of a token, and it currently has no identity of its own, so it inherits the one code that must not be emitted. Give it a distinct code, or pass a flag alongside the error, and let the builder omit the error and error_description pair when that is set. Everything downstream keeps working unchanged, including the Express adapter, because that adapter already imports bearerAuthChallengeResponse from the package entry (packages/middleware/express/src/auth/bearerAuth.ts:2). One site to change, one behaviour to test.

    Two constraints on the shape of the change, both of which your proposal already respects:

    • The omission has to be conditional on at least one auth-param surviving. A bare Bearer with nothing after it is a malformed challenge under section 3, which is what sank fix: implement RFC 6750 Section 3.1 compliance for missing authentication #785. With resourceMetadataUrl set, resource_metadata survives; with requiredScopes set, scope survives; with neither set, keep the error code rather than emit an empty challenge.
    • Keep the JSON body consistent with the header. A client that reads the body will otherwise see error: invalid_token next to a challenge that declares no error, and that contradiction will come back as a separate bug report.

    Why it is worth fixing beyond conformance: the first request from any fresh client is the one that carries no credentials, so invalid_token is the message every new integration sees first. A client that maps invalid_token to refresh-and-retry burns a retry before it ever attempts sign-in, and a client that maps it to clear-stored-tokens drops a working session for a server the user was never signed in to. The challenge is the only signal that lets a client tell those two paths apart.

    Happy to review the PR if you open one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions