BytesAI
MCP

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.

Abid Zaidi4 min read
#mcp#typescript#authentication#security#ai-tools
Adding Authentication to Your MCP Server

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:

bash
npm install @modelcontextprotocol/sdk express zod
npm install -D @types/express
typescript
import 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.

typescript
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:

bash
MCP_API_KEYS=key-abc123,key-def456 node server.js

Clients connect by including the key in the Authorization header:

json
{
  "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:

typescript
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.

MCP Resources: Giving the Model Read Access to Your Data
MCP
Tools get all the attention in MCP tutorials, but Resources are where you give the model read access to live data — files, databases, configs — without making it call a tool every time.
3 min read
Building Your First MCP Server: A Practical Walkthrough
MCP
A hands-on guide to writing a Model Context Protocol server in TypeScript — exposing tools an AI assistant can actually call, and wiring it into Claude and other MCP clients.
4 min read
Getting More From ChatGPT With Projects
ChatGPT
ChatGPT Projects changed how I use the tool daily — persistent instructions, shared context across conversations, and a much cleaner way to separate different kinds of work.
4 min read