AI Agents

MCP Server Authentication: OAuth, API Keys, and Secret Management

How to add authentication to an MCP server - API key validation, OAuth 2.0 flows, secret management.

Most MCP server tutorials skip authentication. They show you how to register a tool, how to handle a request, and how to return a result - and then they deploy the server without any access control. For a local stdio server running only in your own Claude Code session, that is fine. For an HTTP MCP server that is shared across a team, exposed to production data, or calling third-party APIs on behalf of users, unauthenticated access is a serious security problem. This guide covers the two practical authentication patterns for MCP servers: API key validation and OAuth 2.0, with concrete implementation using @modelcontextprotocol/sdk v1.0+.

MCP server authentication
MCP server authentication is access control applied at the transport layer of a Model Context Protocol server - validating that the client connecting to the server (an AI agent or tool orchestrator) is authorised to call its tools, typically via an API key in request headers or an OAuth 2.0 bearer token, before any tool handler is invoked.

Where authentication lives in MCP - the transport layer

MCP is a JSON-RPC 2.0 protocol. Authentication is not a protocol-level concept - MCP does not define an auth scheme. Authentication happens at the transport layer: in HTTP headers for HTTP/SSE transports, and implicitly via process ownership for stdio transports. This means there is no auth field to set in your tool definition - you validate credentials in the transport setup, before any tool call reaches your handler.

The two transports that need authentication:

  • SSEServerTransport - HTTP GET for SSE connection, HTTP POST for client messages. Both requests carry headers that can hold authentication credentials.
  • StreamableHTTPServerTransport - the newer stateless transport. Each request is authenticated independently via headers.

Stdio transports do not need authentication in the traditional sense - the server process is spawned by Claude Code and runs with the same OS-level permissions as the user's session. Authentication for stdio is handled by controlling who can run the process.

Pattern 1 - API key validation

The simplest auth pattern: the client includes an API key in a custom header (X-API-Key or Authorization: Bearer <key>), and the server validates it before establishing the connection:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';


const app = express();
app.use(express.json());

// API key store - in production, use a database with hashed keys
const VALID_API_KEYS = new Set(
  (process.env.MCP_API_KEYS ?? '').split(',').filter(Boolean)
);

function validateApiKey(req: Request, res: Response, next: NextFunction): void {
  const authHeader = req.headers['authorization'];
  const apiKey = authHeader?.startsWith('Bearer ')
    ? authHeader.slice(7)
    : req.headers['x-api-key'] as string;

  if (!apiKey || !VALID_API_KEYS.has(apiKey)) {
    res.status(401).json({ error: 'Invalid or missing API key' });
    return;
  }

  // Attach the validated key to the request for downstream use
  (req as any).apiKey = apiKey;
  next();
}

function createMcpServer(): McpServer {
  const server = new McpServer({
    name: 'production-tools',
    version: '1.0.0',
  });

  server.tool(
    'query_database',
    'Execute a read-only SQL query against the production database.',
    {
      sql: { type: 'string', description: 'SELECT statement only. No DML.' },
    },
    async ({ sql }, context) => {
      // context.requestInfo carries per-request metadata you inject during transport setup
      const apiKey = context.requestInfo?.metadata?.apiKey as string;
      const permissions = await getKeyPermissions(apiKey);

      if (!permissions.includes('database:read')) {
        return { content: [{ type: 'text', text: 'Error: This API key does not have database read permission.' }] };
      }

      const rows = await executeReadOnlyQuery(sql);
      return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
    }
  );

  return server;
}

// Mount with auth middleware
app.post('/mcp', validateApiKey, async (req, res) => {
  const server = createMcpServer();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

  // Inject the validated API key into the transport context
  transport.onMessage = (message) => {
    // Attach metadata for the tool handler
  };

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

app.listen(3000, () => console.log('MCP server running on :3000'));

Pattern 2 - OAuth 2.0 bearer tokens

OAuth 2.0 is the right choice when: the MCP server acts on behalf of individual users (not a shared service key), the underlying APIs the server calls require user-level OAuth tokens, or you need fine-grained per-user permissions. The MCP server validates the bearer token with the OAuth provider on each request:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL(`https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`)
);

interface AuthenticatedUser {
  sub: string;        // user ID
  email: string;
  scopes: string[];
}

async function validateBearerToken(authHeader: string | undefined): Promise {
  if (!authHeader?.startsWith('Bearer ')) {
    throw new Error('Missing or malformed Authorization header');
  }

  const token = authHeader.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      audience: process.env.MCP_API_AUDIENCE,
      issuer: `https://${process.env.AUTH0_DOMAIN}/`,
    });

    return {
      sub: payload.sub!,
      email: payload.email as string,
      scopes: (payload.scope as string ?? '').split(' '),
    };
  } catch (err) {
    throw new Error(`Token validation failed: ${(err as Error).message}`);
  }
}

// Middleware for Express
async function oauthMiddleware(req: Request, res: Response, next: NextFunction): Promise {
  try {
    const user = await validateBearerToken(req.headers['authorization']);
    (req as any).user = user;
    next();
  } catch (err) {
    res.status(401).json({ error: (err as Error).message });
  }
}

Secret management for MCP servers

MCP servers frequently need their own credentials to call downstream APIs - a database password, a third-party API key, a signing secret. These must not be hardcoded or committed to the repository. Three tiers of secret management in order of security:

  1. Environment variables - acceptable for development; never log them and ensure your deployment platform (Railway, Fly, Cloud Run) injects them at runtime rather than baking them into the container image.
  2. AWS Secrets Manager / GCP Secret Manager / HashiCorp Vault - recommended for production. Secrets are fetched at startup and rotated without redeployment. The MCP server needs a service account with read-only access to its specific secrets.
  3. Per-user credential storage - when the MCP server calls APIs on behalf of individual users (OAuth pattern), store the user's encrypted access token in your database, not in environment variables. Fetch and decrypt it per-request using the user's ID from the validated JWT.

Failure modes and security edge cases

  • Token replay attacks - short-lived JWTs (15 min expiry) limit the window for replayed tokens. Always validate the exp claim; the jose library does this by default.
  • API key enumeration - return the same 401 response for invalid keys and missing keys. Do not reveal whether a key exists but lacks permission vs does not exist at all.
  • CORS on HTTP transports - restrict Access-Control-Allow-Origin to known client origins. A wildcard CORS policy on an authenticated MCP server allows any origin to attempt authentication with a stolen token.
  • Tool-level permission checks - do not rely solely on connection-level auth. Check per-tool permissions inside the handler using the validated user context, as shown in the API key example above. A user authenticated to connect does not automatically have permission to call every tool.

Stdio auth - the process ownership model

Claude Code spawns stdio MCP servers as child processes owned by the user running Claude Code. The server inherits the user's environment variables, which is the standard way to pass credentials to stdio servers. For shared team setups where the stdio server needs per-user credentials (uncommon), pass a user-scoped token via an environment variable set in Claude Code's MCP server configuration. For how subagents access MCP tools defined by the parent session, see the Claude Code subagents guide.