Guide · Authentication

MCP server authentication

MCP server authentication lives entirely at the HTTP transport layer, before the MCP session starts. A request that fails authentication returns an HTTP 401 — the client never gets as far as sending the initialize message. This is the correct boundary: auth is enforced once per session at connection time, not once per tool call. Moving auth into tool handlers is a design error — it breaks the MCP session lifecycle and creates an inconsistent security boundary. This guide covers the three practical auth patterns for MCP servers and the specific considerations that apply to the MCP session model.

TL;DR

Add an Express middleware that reads Authorization: Bearer <token> and returns HTTP 401 before transport.handleRequest() if the token is invalid. For API keys, validate against a constant-time comparison to prevent timing attacks. For OAuth 2.0, verify the JWT signature using the authorization server's JWKS endpoint. Store the validated identity in res.locals so the session handler can bind it to the session. Configure AliveMCP with your server's probe API key so it can reach the /mcp endpoint and monitor availability.

Where authentication lives in an MCP server

The MCP session lifecycle starts with the initialize request. Authentication must be enforced before that request is processed — if an unauthenticated request reaches the MCP handler, the session has already started without identity. The correct architecture:

app.post('/mcp', authMiddleware, async (req, res) => {
  // authMiddleware already validated the token
  // res.locals.identity contains the verified caller
  const transport = new StreamableHTTPServerTransport({ sessionIdHeader: 'mcp-session-id' });
  await server.connect(transport);
  await transport.handleRequest(req, res);
});

The middleware runs before the MCP handler. If auth fails, authMiddleware sends a 401 and the MCP handler never executes. This means the session is never created, the client receives an HTTP error (not a JSON-RPC error), and no session state is allocated. This is the right behavior — it is cheaper and simpler than starting a session and then rejecting tool calls.

Do not put authentication inside tool handlers. A tool handler runs after the session is established — failing auth at that point leaves the session in a half-open state and forces the client to understand that a tool-level auth failure means the entire session is invalid. Use HTTP middleware instead.

API key authentication

import { timingSafeEqual, createHash } from 'node:crypto';

function authMiddleware(req, res, next) {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing Authorization header' });
  }

  const providedKey = authHeader.slice(7); // strip "Bearer "
  const validKey = process.env.MCP_API_KEY;

  if (!validKey) {
    // Server misconfigured — fail closed
    return res.status(500).json({ error: 'Server auth not configured' });
  }

  // Use constant-time comparison to prevent timing attacks
  const provided = Buffer.from(createHash('sha256').update(providedKey).digest('hex'));
  const valid    = Buffer.from(createHash('sha256').update(validKey).digest('hex'));

  if (provided.length !== valid.length || !timingSafeEqual(provided, valid)) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

  res.locals.identity = { type: 'api_key', key_prefix: providedKey.slice(0, 8) };
  next();
}

Use timingSafeEqual from node:crypto for all API key comparisons. A naive string comparison (providedKey === validKey) leaks the key length and prefix via timing differences — an attacker can enumerate the correct key one character at a time. The SHA-256 hash before comparison ensures both buffers are the same length regardless of key length, making timingSafeEqual safe to use. Store the API key in an environment variable — see MCP server environment variables for the correct secrets management patterns.

For multi-tenant servers where each caller has their own key, store keys in a database and look up by key prefix (the first 8 characters) to avoid a full-table scan, then verify the full key with constant-time comparison. Log the key prefix (never the full key) in your structured logs so you can correlate sessions to callers without exposing the secret.

OAuth 2.0 bearer token (JWT)

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL(process.env.AUTH_JWKS_URI) // e.g. https://auth.example.com/.well-known/jwks.json
);

async function authMiddleware(req, res, next) {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing bearer token' });
  }

  const token = authHeader.slice(7);
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: process.env.AUTH_ISSUER,     // e.g. "https://auth.example.com"
      audience: process.env.AUTH_AUDIENCE, // e.g. "mcp-server"
    });

    // Verify required claims
    if (!payload.sub) {
      return res.status(401).json({ error: 'Token missing sub claim' });
    }

    res.locals.identity = { type: 'oauth2', sub: payload.sub, scopes: payload.scope?.split(' ') ?? [] };
    next();
  } catch (err) {
    // jose throws on expired, invalid signature, wrong issuer/audience
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

The jose library handles JWKS fetching, key rotation, and JWT verification. createRemoteJWKSet caches the JWKS response and automatically re-fetches on a cache miss (when a new key ID is encountered). Set the issuer and audience claims — a JWT from any other issuer or audience will be rejected even if the signature is valid. This prevents token reuse across services.

Cache the JWKS at the module level (not per-request) so your server does not hit the authorization server's JWKS endpoint on every MCP session. createRemoteJWKSet from jose does this correctly by default. The cache is invalidated automatically when a new key ID appears in a token — this handles key rotation transparently.

Session-bound identity

After verifying the token at the HTTP layer, bind the caller's identity to the session so tool handlers can use it for access control without re-validating on every call:

// Session identity store — keyed by MCP session ID
const sessionIdentities = new Map();

app.post('/mcp', authMiddleware, async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  const identity  = res.locals.identity;

  // Bind identity to session on first request (initialize)
  if (sessionId && !sessionIdentities.has(sessionId)) {
    sessionIdentities.set(sessionId, identity);
    // Clean up when session ends
    res.on('close', () => sessionIdentities.delete(sessionId));
  }

  const transport = new StreamableHTTPServerTransport({ sessionIdHeader: 'mcp-session-id' });

  // Inject identity into tool handlers via server middleware
  server.use(async (context, next) => {
    context.identity = sessionIdentities.get(context.sessionId);
    return next();
  });

  await server.connect(transport);
  await transport.handleRequest(req, res);
});

The session identity map is in-process memory. For horizontally scaled deployments, use Redis to store session identities so they are accessible across instances. Key by session ID with a TTL matching your session timeout.

Monitoring authenticated servers with AliveMCP

AliveMCP needs to authenticate as a probe client to reach /mcp endpoints that require auth. Configure a dedicated probe API key with read-only scope (if your auth supports scopes) — this key should only be used by the monitoring system and should be rotatable independently of your user-facing keys. In AliveMCP, set the probe's Authorization header to Bearer <probe-key>.

A 401 response from the probe means either the server is rejecting valid credentials (rotation mismatch — update the probe key in AliveMCP after rotating) or the auth middleware crashed (a 500 would indicate the latter). AliveMCP logs the HTTP status code of every probe attempt so you can distinguish auth failures from server crashes. See MCP server security monitoring for how to alert on unexpected 401 spikes that may indicate credential enumeration attacks.

Related questions

Should I use API keys or OAuth 2.0 for my MCP server?

API keys for server-to-server integrations where the caller is a backend service with a long-lived secret. OAuth 2.0 bearer tokens for user-facing access where you want short-lived tokens, token revocation, and scoped permissions. API keys are simpler to implement and reason about — use them unless you specifically need the OAuth 2.0 features. The security properties are equivalent if you use constant-time comparison for API keys and JWKS-verified JWT validation for OAuth 2.0.

How do I handle token expiration mid-session?

The MCP protocol does not have a mid-session re-authentication mechanism. If the client's OAuth 2.0 token expires during a long session, the next request to /mcp will receive a 401 and the session ends. The client must start a new session with a fresh token. This is a known limitation of the current MCP spec. Design tool interactions to complete within the token's TTL, or use API keys (which do not expire) for long-running sessions. If your use case requires long-lived sessions with expiring tokens, the workaround is to use a session token that is separate from the OAuth token — the client exchanges the OAuth token for a server-issued session token at session start, and the session token has a longer TTL.

How do I add per-tool authorization (some tools require elevated access)?

After validating auth in the middleware and binding the identity to the session, check the identity's scopes or role in the tool handler. Return a tool-level error (isError: true) if the caller lacks the required permission — do not throw an unhandled exception. Log the denied call with the caller's identity prefix and the tool name. This is application-level access control on top of authentication — authentication establishes who is calling, authorization determines what they can call. See MCP server rate limiting for how to combine auth identity with per-caller rate limits.

Does the MCP spec define an authentication mechanism?

The MCP 2025 specification does not mandate a specific authentication mechanism — it only requires that implementations protect the transport layer. The recommended pattern is Authorization: Bearer <token> at the HTTP layer, following standard OAuth 2.0 conventions. The spec is intentionally agnostic to allow different auth patterns depending on the deployment context (API gateway, direct HTTPS, mTLS for internal services). Future spec revisions may standardize an auth mechanism as the ecosystem matures.

Further reading