AI Agents
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 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:
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.
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'));
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 });
}
}
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:
exp claim; the jose library does this by default.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.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.