Adding Authentication to Your MCP Server
Exposing an MCP server beyond localhost means thinking about who can call it. This walkthrough adds API key authentication to a TypeScript MCP server without over-engineering it.
On this page
A local MCP server running over stdio has a simple security model: only the person running the process can talk to it. The moment you deploy a server over HTTP so it can be shared — across a team, inside an app, or as a service — that model breaks. You need to know who is calling and whether they should be allowed to.
This walkthrough adds API key authentication to an HTTP MCP server. It is not the only approach, but it is the right default for most internal tools.
Where authentication fits in the MCP stack
MCP servers running over HTTP use a transport based on Server-Sent Events (SSE). The connection is initiated by the client, and messages flow bidirectionally after that. Authentication needs to happen at connection time — before any tool calls are allowed through.
The SDK does not enforce authentication for you. You wire it in yourself by intercepting the HTTP request before handing it to the transport.
Setting up an HTTP MCP server
Start with the server from the first MCP article, adapted for HTTP:
npm install @modelcontextprotocol/sdk express zod
npm install -D @types/expressimport express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { z } from "zod";
const app = express();
const server = new McpServer({ name: "secure-mcp", version: "1.0.0" });
server.registerTool(
"ping",
{
title: "Ping",
description: "Returns pong. Used to verify the server is reachable.",
inputSchema: {},
},
async () => ({
content: [{ type: "text", text: "pong" }],
}),
);
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", express.json(), async (req, res) => {
// transport.handlePostMessage is called here in a full implementation
});
app.listen(3001);This works but anyone who can reach the port can call your tools.
Adding API key middleware
The cleanest approach: validate a bearer token on every inbound request before it reaches the MCP transport.
const VALID_KEYS = new Set(
(process.env.MCP_API_KEYS ?? "").split(",").filter(Boolean),
);
function requireApiKey(
req: express.Request,
res: express.Response,
next: express.NextFunction,
) {
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token || !VALID_KEYS.has(token)) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
// Apply before both endpoints
app.get("/sse", requireApiKey, async (req, res) => { /* ... */ });
app.post("/messages", requireApiKey, express.json(), async (req, res) => { /* ... */ });Set MCP_API_KEYS as a comma-separated list in your environment:
MCP_API_KEYS=key-abc123,key-def456 node server.jsClients connect by including the key in the Authorization header:
{
"mcpServers": {
"secure-mcp": {
"url": "https://your-server.internal/sse",
"headers": {
"Authorization": "Bearer key-abc123"
}
}
}
}Per-client keys and auditing
The multi-key approach above gives you more than just on/off access control. Use a different key per client and you can:
- Rotate one key without disrupting others.
- Log which client called which tool by tagging requests with the key's identity.
- Revoke a single client by removing its key from the set.
For audit logging, extend the middleware to attach identity to the request:
const KEY_IDENTITIES: Record<string, string> = {
"key-abc123": "team-backend",
"key-def456": "ci-pipeline",
};
function requireApiKey(req, res, next) {
const token = (req.headers.authorization ?? "").slice(7);
if (!token || !VALID_KEYS.has(token)) {
res.status(401).json({ error: "Unauthorized" });
return;
}
// Attach identity for downstream logging
(req as any).clientId = KEY_IDENTITIES[token] ?? "unknown";
next();
}Then in your tool handler or a request logger, req.clientId tells you who made the call.
What not to do
Two mistakes I see frequently:
Hardcoding keys in source. Even in private repositories, secrets in code get leaked through CI logs, screenshots, and accident. Always use environment variables.
No expiry or rotation plan. API keys that never expire are a quiet risk. Add a rotation step to your deployment runbook so it actually happens.
When to go beyond API keys
API keys are right for server-to-server calls where you control both ends. If you are building an MCP server that end users connect to with their own identities, you want OAuth — the MCP specification has a defined OAuth flow for this. That is a larger topic, but the foundation here (intercepting at the HTTP layer) is the same.