MCP Server Security: Protecting Your Tools with API Keys and OAuth 2.0
MCP tools can query databases, call APIs, and execute code. Here's how to lock them down properly — covering API key authentication, OAuth 2.0 integration, rate limiting, and the security model defined in the MCP spec.
An MCP server is a real HTTP endpoint that executes code. When an AI assistant calls one of your tools, it's making a network request to your server — and depending on what that tool does, the consequences of unauthorized access can range from minor (leaked data) to severe (database writes, emails sent, API quotas consumed).
The MCP specification takes security seriously: it defines authentication patterns, requires input validation, and includes a security model based on explicit user consent. This guide explains the practical side — how to implement authentication on your MCP server and choose the right security model for your use case.
Why MCP Security Is Different from Regular API Security
Standard API security assumes a human developer configuring an API client. MCP is different: the caller is an AI model that decides autonomously when and how to use your tools, based on what a user asked for.
This creates a few unique challenges:
- Tool call injection — A malicious prompt can trick an AI into calling tools in unintended ways. Your server can't assume the tool was called with benign intent just because the call came from a legitimate client.
- Credential exposure — If your auth token is in a client-side config file (which most MCP client configs are), it can be read by anyone with access to that machine.
- Scope explosion — Without rate limiting, a single misbehaving AI loop can exhaust a third-party API quota in seconds.
The MCP spec says: "Servers MUST validate all tool inputs, implement proper access controls, and rate limit tool invocations." These aren't optional suggestions.
The Four Authentication Modes
MCP servers can operate in four distinct security modes. Choosing the right one depends on who your intended users are and how sensitive the data is.
1. Public (No Authentication)
The server is open — any client with the URL can call your tools.
Use for: Public datasets, open demo servers, tools that return only non-sensitive public data.
Never use for: Anything that writes data, accesses private APIs, queries databases, or touches anything you wouldn't publish on the internet.
Public mode is a legitimate choice for genuinely open tools. But it's not the right default — it should be a deliberate decision.
2. Unlisted (Secret URL)
A 48-character random secret key is embedded directly in the server URL. No auth header is needed — the URL itself is the credential.
https://your-subdomain.mcpcore.io/mcp/Xk8mNpQ7rLwJvY2cTqH5bA3eFzDsU1oI9gP4nMxCW6
Use for: Single-user tools, temporary access, quick sharing with a specific person.
Limitation: Rotating the key means updating the URL in every client config. And unlike a proper auth header, the key is visible in logs, browser history, and anywhere URLs get recorded. For shared or long-lived access, API Key mode is cleaner.
3. API Key (Bearer Token)
Clients send a generated API key in the Authorization: Bearer header with every request. The key is valid until you explicitly revoke it.
POST /mcp HTTP/1.1 Authorization: Bearer sk-mcpcore-AbCdEfGhIjKlMnOpQr... Content-Type: application/json
This is the most practical mode for most production use cases. The key is decoupled from the URL, can be rotated without changing the endpoint, and is standard HTTP authentication.
Client config for Claude Desktop:
{ "mcpServers": { "my-server": { "command": "npx", "args": [ "-y", "mcp-remote@latest", "https://your-subdomain.mcpcore.io/mcp", "--header", "Authorization: Bearer YOUR_KEY" ] } } }
Implementing API key validation yourself:
app.post("/mcp", (req, res, next) => { const authHeader = req.headers["authorization"] ?? ""; const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token || token !== process.env.MCP_API_KEY) { return res.status(401).json({ error: "Unauthorized" }); } next(); });
For multi-user setups, store keys in a database and look them up per request rather than comparing to a single env variable.
4. OAuth 2.0
OAuth delegates authentication to an external identity provider. Clients complete an OAuth flow before making any tool calls. The server validates tokens on every request using the provider's JWKS endpoint.
Use for: Multi-tenant applications where different users should have different permissions, or when you want to use your existing identity infrastructure.
OAuth follows RFC 8414 — the server exposes a metadata endpoint (usually /.well-known/oauth-authorization-server) that clients use to discover the authorization endpoint, token endpoint, and JWKS URI.
The configuration is more involved:
import jwksClient from "jwks-rsa"; import jwt from "jsonwebtoken"; const client = jwksClient({ jwksUri: process.env.JWKS_URI }); async function validateToken(token) { const decoded = jwt.decode(token, { complete: true }); const key = await client.getSigningKey(decoded.header.kid); return jwt.verify(token, key.getPublicKey(), { audience: process.env.OAUTH_AUDIENCE, issuer: process.env.OAUTH_ISSUER, }); }
OAuth is the right choice when your tools need to enforce user-level permissions — for example, a tool that queries a database where each user should only see their own records.
Input Validation: The Layer That Actually Stops Attacks
Authentication controls who can call your tools. Input validation controls what those calls can do.
The MCP spec's inputSchema field defines expected parameter types. But JSON Schema validation only checks structure — it doesn't prevent logical attacks. Always validate semantics too:
server.tool( "query_orders", "Query orders by date range", { userId: z.string().uuid(), fromDate: z.string().datetime(), toDate: z.string().datetime(), }, async ({ userId, fromDate, toDate }) => { // Validate range — prevent massive queries const from = new Date(fromDate); const to = new Date(toDate); const diffDays = (to - from) / (1000 * 60 * 60 * 24); if (diffDays > 90) { return { content: [{ type: "text", text: "Date range cannot exceed 90 days." }], isError: true, }; } // Use parameterised queries — never interpolate params into SQL directly const rows = await db.query( "SELECT * FROM orders WHERE user_id = $1 AND created_at BETWEEN $2 AND $3", [userId, fromDate, toDate] ); return { content: [{ type: "text", text: JSON.stringify(rows) }] }; } );
Key rules:
- Never interpolate
params.*values directly into SQL strings — always use parameterised queries - Validate ranges and limits — an AI can ask for unbounded data; always cap it
- Sanitize outputs — don't return sensitive fields unless explicitly needed (passwords, internal IDs, tokens)
Rate Limiting
Rate limiting protects your downstream services from being hammered. In the MCP spec, servers are explicitly required to implement it. The right strategy depends on what your tools access:
- Third-party APIs — rate limit to stay under the API provider's limits (check their docs)
- Databases — limit concurrent queries to avoid connection pool exhaustion
- Write operations — rate limit more aggressively than reads
A simple per-key rate limiter:
const callCounts = new Map(); // In production, use Redis function checkRateLimit(apiKey, maxPerMinute = 60) { const now = Date.now(); const entry = callCounts.get(apiKey) ?? { count: 0, resetAt: now + 60_000 }; if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + 60_000; } entry.count++; callCounts.set(apiKey, entry); return entry.count <= maxPerMinute; }
For production, use Redis instead of an in-memory map — so rate limits survive restarts and apply across multiple instances.
Secrets: The Right Way
Tool code often needs credentials — database passwords, API keys, service tokens. These should never be in your source code or returned in tool responses.
The standard approach is environment variables. In tool code:
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const db = new Pool({ connectionString: process.env.DATABASE_URL });
If you're using a managed MCP platform, secrets are typically stored encrypted and injected at runtime — you reference them by name without ever seeing the raw value in logs or responses. On MCPCore, secrets are encrypted with AES-256 at rest and referenced in tool code as env.MY_KEY, without the value ever appearing in traffic logs or error output.
The Security Checklist
Before exposing any MCP server to a real AI client, verify:
- Authentication is required — no unintentional public access
- All tool inputs are validated against expected types, ranges, and formats
- SQL queries use parameterised statements — no string interpolation
- Secrets are in environment variables, not source code
- Rate limiting is configured
- Tool descriptions don't leak implementation details or internal URLs
- Error messages don't expose stack traces or sensitive data to the AI's context
- The server is behind HTTPS with a valid certificate
Security isn't a feature you add at the end — it's a property of the design. The tools you expose to an AI assistant have real-world consequences. Treat them like a production API, because that's exactly what they are.
The MCP security model is defined in the Model Context Protocol specification. The current protocol version is 2025-06-18.