How to Add API Key Auth to Your MCP Server (The Right Way)
Most MCP servers that accept API keys do it wrong - they put them in tool parameters where they end up in LLM logs and the context window. Here's how to use HTTP headers instead, with code examples for the TypeScript MCP SDK.
If your MCP server needs an API key from the user, you have two choices: accept it as a tool parameter (wrong) or validate it as an HTTP header at connection time (right). The difference matters because tool parameters are visible to the LLM, logged by providers, and one prompt injection away from leaking. HTTP headers never touch the context window.
Why this matters
When an API key lives in a tool parameter, three things happen:
- 1.The LLM sees the key in its context window (it has to, in order to construct the tool call)
- 2.The key appears in plain-text logs at your LLM provider (Anthropic, OpenAI)
- 3.A prompt injection attack can trick the LLM into printing the key in the chat
HTTP header auth avoids all three. The key travels in the request header, gets validated by your server before any tool logic runs, and never enters the LLM context.
The wrong way (tool parameter)
This is what most MCP servers do. It works, but it leaks:
// DON'T DO THIS
server.tool("search", {
query: z.string(),
api_key: z.string().describe("Your API key"),
}, async ({ query, api_key }) => {
// api_key is now in the LLM context window,
// in provider logs, and one injection away from leaking
const results = await searchApi(query, api_key);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
});The LLM constructs a call like { "query": "transformers", "api_key": "sk-abc123" }. That JSON payload gets logged everywhere.
The right way (HTTP header auth)
Validate the API key when the client connects, before any tools run:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
// Tools don't need an api_key parameter
server.tool("search", {
query: z.string(),
}, async ({ query }) => {
// apiKey was already validated at connection time
const results = await searchApi(query, authenticatedKey);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
});
// In your HTTP handler (Express, Hono, etc.)
app.all("/mcp", async (req, res) => {
const authHeader = req.headers["authorization"];
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing API key" });
}
const apiKey = authHeader.slice(7);
if (!isValidKey(apiKey)) {
return res.status(403).json({ error: "Invalid API key" });
}
// Store the validated key for this session
authenticatedKey = apiKey;
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
await transport.handleRequest(req, res);
});Now the tool call looks like { "query": "transformers" }. No key in the payload, no key in the context window, no key in LLM logs.
How clients send the header
Users set this up in their MCP client config. In Claude Desktop:
{
"mcpServers": {
"my-server": {
"url": "https://my-server.example.com/mcp",
"headers": {
"Authorization": "Bearer sk-abc123"
}
}
}
}Cursor, VS Code, and other MCP clients have similar config fields for custom headers. The key never appears in the chat or in any tool call.
What about OAuth?
For MCP servers that need user-level auth (not just an API key), the MCP spec supports OAuth 2.0 flows. That's a bigger topic, but the principle is the same: authenticate at the transport layer, not in tool parameters.
For most MCP servers, a simple Bearer token in the Authorization header is enough.
Checklist
- >API key validated in HTTP handler, not in tool parameters
- >Tools have zero auth-related parameters
- >Return 401 for missing keys, 403 for invalid keys
- >Use HTTPS (TLS) so the header isn't sent in plaintext
- >Document the expected header format for your users