Apr 9, 2026 · 5 min read

How MCP Servers Actually Handle Authentication

Everyone talks about what MCP tools can do. Nobody talks about how the server knows who's asking — and that turns out to be a genuinely hard problem.

MCP OAuth Authentication System Design API Security

Everyone talks about MCP tools and what they can do. Nobody talks about how an MCP server knows who's asking.

That turns out to be a genuinely hard problem — and the solution is more interesting than most people realize.

The Problem Is Harder Than It Looks

A REST API has a simple job: receive a request, check a token, respond. The client is always the same kind of thing — a web app, a mobile client, a backend service. You pick an auth strategy, implement it once, done.

MCP is different. An MCP server might receive connections from:

  • Claude.ai — a web client that can send Authorization headers
  • Claude Desktop / Claude Code — a native app where injecting headers is trivial
  • Third-party agents — that may only support URL-based token delivery
  • Automated workflows — running headlessly with machine-generated tokens
  • Human developers — who created API tokens with specific scope restrictions

That's not one auth problem. It's five. And they all need to work through the same server, on the same endpoints, with consistent access control.

The Token Landscape

A production MCP server typically needs to support at least two token types.

API Tokens — the developer credential. The format encodes the workspace ID directly (e.g. sk_ws_7e3390d9…). The server extracts it, looks up the SHA-256 hash in the workspace database, and resolves the token to a user + scope list. These tokens are long-lived, created explicitly by developers, and scoped to specific operations.

OAuth Tokens — the user-delegated credential (e.g. user_at_abc123…). Issued through a standard OAuth 2.1 PKCE flow. Stored as a hash in the auth database, tied to a specific user + workspace + scope string. Short-lived, refreshable, and the right choice when a user is granting an external application access on their behalf.

Both token types share the same downstream result: a userId, a workspaceId, and a set of scopes. Everything downstream is the same — auth is the only branching point.

The Auth Function

The core is a single authenticateRequest() function that runs on every MCP request. It extracts the token, detects the format by prefix, performs a SHA-256 hash lookup in the appropriate database (workspace DB for API tokens, auth DB for OAuth tokens), checks expiry, and returns a normalized { userId, scopes } object — or null for a 401.

Two token formats, two database lookups, one unified result shape. Clean separation.

Transports: Where the Token Comes From

MCP supports multiple transports, and each delivers tokens differently.

Streamable HTTP (the modern standard) uses a standard Bearer token in the Authorization header — Authorization: Bearer sk_ws_…. Used by Claude Desktop, Claude Code, and any well-behaved MCP client.

SSE (legacy, still common) uses the same header-based auth over a persistent event stream. The auth layer doesn't care about the underlying transport.

Token-in-URL (for constrained clients) — some clients can't inject custom headers. This format encodes the token directly in the URL path: /api/mcp/sk_ws_.../sse. The server extracts the token, injects it as a Bearer header, and rewrites the URL to the standard route before auth runs. The auth function never sees anything unusual — normalization happens upstream.

Scopes: What the Token Can Do

Authentication answers "who are you." Scopes answer "what are you allowed to do."

The scope format is {module}:{action}:{reach}:

  • contacts:read:all — read any contact
  • contacts:read:own — read only contacts you own
  • tasks:create:all — create tasks for anyone
  • mcp:tools — full access to all MCP tools (coarse-grained)

When a tool call arrives, the server checks scope before executing. null scopes means unrestricted — this preserves backward compatibility for tokens issued before scopes were introduced. New tokens always carry explicit scope lists.

The :own:all hierarchy is enforced: a token with contacts:read:all satisfies a requirement for contacts:read:own, but not vice versa.

OAuth Discovery: Being a Good RFC Citizen

A production MCP server doesn't just accept tokens — it advertises how to obtain them. This is the RFC 9728 (Protected Resource Metadata) and RFC 8414 (Authorization Server Metadata) pattern.

When a client gets a 401, the WWW-Authenticate response header points to a /.well-known/oauth-protected-resource endpoint. That endpoint lists all supported scopes, bearer methods, and authorization servers. A well-behaved client can follow that pointer, discover the authorization server, complete the OAuth flow, and retry — without any hardcoded configuration. This is what makes MCP integrations self-describing at the protocol level, not just the tool level.

The Full Flow

SSE Streamable HTTP Token-in-URL CLIENT Bearer token extractToken() header · query · URL path sk_ws_* user_at_* API Token Long-lived · Scoped OAuth Token Short-lived · PKCE SHA-256 SHA-256 Workspace DB api_tokens · hash lookup Auth DB oauth_tokens · hash lookup verified verified { userId, scopes } normalized auth context Scope Check per tool call Tool Execute isolated context

Three transports feed into a single extractToken() normalizer. Both token paths converge to the same { userId, scopes } context before any tool logic runs.

Three transport types feed into the server. Every request passes through extractToken(), which normalizes header, query, and URL-embedded tokens into the same shape. The token format determines which database handles the lookup — workspace DB for API tokens, auth DB for OAuth tokens — but both paths resolve to the same { userId, scopes } context. From there, scope is checked per tool call, and execution runs in an isolated workspace context.

Auth runs once per session (SSE) or once per request (Streamable HTTP). Scope is checked every time.

What Makes This Architecture Work

A few design decisions that make this robust in production:

Tokens hash-only in DB. The raw token never touches the database — only its SHA-256 hash. A database dump reveals nothing usable.

Workspace isolation. API tokens are scoped to a workspace, and the workspace ID is encoded in the token itself. The server validates the extracted workspace ID matches the route — cross-workspace token reuse is impossible by construction.

No scopes = full access. This backward-compatibility rule sounds dangerous but is intentional: old integrations that predate scopes continue working. New tokens always carry explicit scope lists. The two populations are separable, and scope enforcement can be rolled out incrementally.

Scope checked at MCP layer, enforced at API layer. MCP scopes are a coarse gate — they determine which tools are visible and callable. The underlying API endpoints run their own access checks independently. Defense in depth: even if the scope check had a bug, the API layer catches it.

The Takeaway

Authentication for MCP isn't just "slap a Bearer token on it." A production server needs to handle multiple token formats, multiple transports, granular scope enforcement, and RFC-compliant discovery — while keeping the auth logic unified and the downstream execution context consistent.

The pattern that works: normalize everything to { userId, scopes } as early as possible, keep token-format-specific logic in one place, and let everything downstream operate on the normalized result. The complexity lives at the boundary; the server logic stays clean.

If you're building an MCP server that will see real traffic, start with the auth layer. Everything else is easier once that's solid.