Authenticating MCP Servers
MCP server authentication is the piece most tutorials skip and most production incidents come back to. A Model Context Protocol server sits between an LLM client (Claude Code, Claude Desktop, a custom agent) and whatever it's proxying, a database, a SaaS API, an internal service. If you don't lock down who can call that server and what it's allowed to do on your behalf, you've built an open door with a very capable operator standing next to it. This guide walks through the authentication models MCP actually supports, how to implement each one, and where teams get it wrong.
Before writing any code, get the mental model straight: there are two separate trust boundaries in an MCP deployment. One is between the client (the agent) and the MCP server, that's "can this agent talk to my tools at all." The other is between the MCP server and the upstream API or database it wraps, that's "what credentials does the server use once it's authenticated the client." Conflating these two is the single most common design mistake, and it's where most of this article's advice concentrates.
Why MCP server authentication is different from normal API auth
A regular REST API has a predictable caller: a browser, a mobile app, another service. You know roughly what requests to expect and you can rate-limit or validate against a known shape. An MCP server's caller is an LLM deciding, turn by turn, what tool to invoke and with what arguments. That changes the threat model in three ways.
First, the caller can be manipulated. If your MCP server exposes a delete_records tool and the model is fed a malicious prompt (via a document it's summarizing, a webpage it fetched, a poisoned tool description from another server), it might call that tool without the human ever asking for it. Authentication doesn't fully solve this, but scoping credentials tightly limits the blast radius.
Second, MCP servers are frequently run locally, over stdio, with zero network exposure at all. In that mode "authentication" often just means "who can start this process," which pushes the real security boundary to the OS and file permissions. That's fine for a laptop dev tool; it's not fine if you're wrapping payment APIs.
Third, remote MCP servers (the ones exposed over HTTP/SSE for shared or hosted use) are the ones that need real authentication, and this is where the MCP spec has converged on OAuth 2.1 as the recommended mechanism, not because it's fancy, but because it lets you separate "who is this agent acting for" from "what secret does the server use to call the upstream API."
The three authentication patterns you'll actually use
Pattern 1: No auth, local stdio. The server runs as a subprocess of the client, communicates over stdin/stdout, and never touches the network. Security comes from process isolation and file permissions, not from the protocol. This is the default for local dev tools: a filesystem server, a git server, a local SQLite server.
Pattern 2: Static API key or bearer token. The MCP server is exposed over HTTP, and callers must present a pre-shared secret in a header. This is the simplest remote setup and appropriate for internal tools with a small, known set of callers.
Pattern 3: OAuth 2.1 with dynamic client registration. The MCP server acts as an OAuth resource server (sometimes also the authorization server), issuing short-lived access tokens to clients after a proper authorization flow. This is the pattern for anything multi-tenant, anything exposed to third-party agent clients, or anything that needs per-user scoping.
Most real deployments end up needing all three at different layers: stdio for local dev, an API key for a quick internal proof of concept, and OAuth once the server needs to serve more than one trusted team.
Setting up local stdio servers securely
Even without network auth, a stdio MCP server still needs boundaries. The pattern that works:
import os
import sys
from mcp.server import Server
from mcp.server.stdio import stdio_server
server = Server("internal-tools")
REQUIRED_ENV = "MCP_TOOLS_TOKEN"
def check_launch_token():
expected = os.environ.get(REQUIRED_ENV)
if not expected:
print(f"missing {REQUIRED_ENV}, refusing to start", file=sys.stderr)
sys.exit(1)
check_launch_token()
@server.tool()
async def read_config(path: str) -> str:
# never resolve outside an allowlisted root
root = os.environ["MCP_ALLOWED_ROOT"]
full = os.path.realpath(os.path.join(root, path))
if not full.startswith(os.path.realpath(root)):
raise PermissionError("path escapes allowed root")
with open(full) as f:
return f.read()
async def main():
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())The launch token isn't network authentication, it's a guard against a stray process starting the server with the wrong environment. The real work is in read_config: never trust a path argument from the model without resolving it against an allowlisted root. This matters because the model is the one constructing arguments, and a prompt injection can absolutely get it to try ../../.ssh/id_rsa.
Store any upstream secrets (API keys for the service you're wrapping) in environment variables injected by the client's MCP config, not hardcoded, and not passed as tool arguments where the model could see or leak them in its reasoning trace.
Static API key auth for remote servers
When you move an MCP server to HTTP so a team can share it, the minimum viable auth is a bearer token checked on every request. Streamable HTTP transport (the current MCP transport for remote servers) makes this straightforward because it's just HTTP headers.
import express from "express";
import { randomUUID } from "crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
const validKeys = new Set(process.env.MCP_API_KEYS.split(","));
function requireApiKey(req, res, next) {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token || !validKeys.has(token)) {
return res.status(401).json({ error: "invalid or missing api key" });
}
req.mcpClientId = token.slice(0, 8); // for logging, never log full key
next();
}
app.post("/mcp", requireApiKey, async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
// attach your server instance and handle the request
await transport.handleRequest(req, res, req.body);
});
app.listen(3333, () => console.log("mcp server on :3333"));Three details make this actually safe rather than just present:
- Compare tokens with a constant-time check in anything security-sensitive,
crypto.timingSafeEqualin Node,hmac.compare_digestin Python. A naive===string comparison leaks timing information. - Rotate keys by supporting two valid values during a transition window (
MCP_API_KEYSas a comma-separated list handles this), then cut over. - Put this behind TLS. A bearer token over plain HTTP is a token broadcast to anyone on the network path.
Static keys are fine for a handful of known internal clients. They fall apart the moment you need per-user permissions, because every holder of the key can do everything the key can do.
OAuth 2.1: the pattern MCP actually standardizes on
The MCP authorization spec (as of the 2025-06-18 revision and its 2026 refinements) treats the MCP server as an OAuth 2.1 resource server. The server doesn't necessarily issue tokens itself, it validates tokens issued by a proper authorization server (your existing identity provider, or a small dedicated one) and enforces scopes on each tool call. The flow, end to end:
- The MCP client attempts an unauthenticated request and gets a
401with aWWW-Authenticateheader pointing to the authorization server's metadata. - The client discovers endpoints via
/.well-known/oauth-authorization-server. - The client registers dynamically (Dynamic Client Registration, RFC 7591) if it hasn't been registered before, no manual "create an OAuth app" step required.
- The user completes an authorization code flow with PKCE, the browser opens, they log in, they approve scopes.
- The client exchanges the code for a short-lived access token (and a refresh token) and attaches it as a bearer token on every subsequent MCP request.
Here's a minimal resource-server side check, assuming you're using a provider like a standard OIDC issuer for the actual token minting:
import time
import jwt
from jwt import PyJWKClient
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
ISSUER = "https://auth.example.com"
AUDIENCE = "mcp-server-prod"
jwks_client = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
def verify_token(authorization: str = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
token = authorization.split(" ", 1)[1]
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=AUDIENCE,
issuer=ISSUER,
)
except jwt.PyJWTError as e:
raise HTTPException(401, f"invalid token: {e}")
if claims.get("exp", 0) < time.time():
raise HTTPException(401, "token expired")
return claims
@app.post("/mcp")
async def mcp_endpoint(claims: dict = Header(default=None, alias="__unused")):
# in practice, use FastAPI's Depends(verify_token) here
...The two things worth calling out that people miss: validate the audience claim so a token minted for a different API can't be replayed against your MCP server, and validate scopes per tool, not just "is this token valid." A token that only has a read:tickets scope should get a 403 when the model tries to call delete_ticket, and that check needs to live inside each tool handler, not just at the gateway.
def require_scope(claims: dict, scope: str):
granted = claims.get("scope", "").split()
if scope not in granted:
raise HTTPException(403, f"missing scope: {scope}")
@server.tool()
async def delete_ticket(ticket_id: str, claims: dict):
require_scope(claims, "write:tickets")
# proceedSeparating "who's calling" from "what the server calls with"
This is the design decision that determines whether your MCP server is safe to scale past one user. Two approaches:
Token pass-through. The MCP server forwards the user's own OAuth token to the upstream API. Every action is attributed to the actual user, and the upstream service's own permission system does the enforcement. This is the right default whenever the upstream API supports per-user OAuth (Google Workspace, GitHub, Slack, most modern SaaS).
Server-held service credentials. The MCP server authenticates callers however it likes, but talks to the upstream API with its own service account or API key, applying its own authorization logic in between. This is necessary when the upstream system has no per-user auth (a shared database, a legacy internal API), but it means your MCP server's authorization logic is now the only thing standing between a compromised or manipulated agent and full access. Audit logging becomes mandatory here, log the calling user's identity, the tool called, the arguments, and the outcome, on every single call.
Never do the naive third option: hardcode a powerful upstream API key and skip auth on the MCP server itself because "it's just for internal use." That key ends up in the model's tool-call arguments or error messages more often than teams expect, and "internal use" servers get exposed to the public internet by a misconfigured reverse proxy with depressing regularity.
Token storage and refresh on the client side
If you're building an MCP client (not just a server), tokens need a home that survives restarts without leaking into logs or shell history.
- Store access and refresh tokens in the OS keychain (Keychain on macOS, Credential Manager on Windows,
libsecreton Linux), not in a plaintext config file, and definitely not in an environment variable that gets dumped into every subprocess's inherited environment. - Treat the access token as disposable, refresh proactively a minute or two before expiry rather than waiting for a
401and retrying, which avoids a race where a long tool call fails mid-execution. - Scope refresh tokens to the narrowest lifetime your identity provider allows for machine-driven flows, and revoke them explicitly when a user disconnects an MCP server from their client.
Common mistakes worth naming directly
Treating tool descriptions as trusted input. A malicious or compromised MCP server can put anything it wants in its tool descriptions, including instructions aimed at the model rather than the human. This isn't strictly an "authentication" issue, but it lives in the same trust boundary: only connect MCP servers you've vetted, and prefer an allowlist of approved servers over letting users add arbitrary endpoints in a shared deployment.
Skipping the audience check on JWTs. Without it, a token minted for photos-api can be replayed against mcp-finance-server if both trust the same issuer. This is a five-line fix and it's absent from a surprising number of quickstart tutorials.
Logging full tokens. Debug logging that prints request headers verbatim will happily print bearer tokens. Redact the Authorization header specifically before any logging middleware runs, don't rely on remembering to do it per endpoint.
Long-lived static keys with no rotation path. If revoking a compromised key means editing code and redeploying, you'll delay revocation exactly when speed matters most. Keep keys in a store you can update without a deploy, and support at least two valid keys simultaneously so rotation doesn't cause an outage.
One scope to rule them all. A single full_access scope on every token means every integration, however narrow its actual job, can do everything. Define scopes per capability (read:orders, write:refunds, admin:users) and issue tokens with only what the specific client needs.
A minimal checklist before shipping a remote MCP server
- TLS terminated in front of the server, no plaintext HTTP path reachable.
- Bearer token or OAuth access token required on every tool-invoking request, not just the initial handshake.
- Constant-time comparison for any static secret check.
- Scopes enforced inside each tool handler, not only at the router.
- Audience and issuer validated on every JWT, expiry checked explicitly.
- Upstream credentials never appear in tool arguments, tool results, or error messages returned to the model.
- Structured audit log per tool call: caller identity, tool name, arguments (redacted where sensitive), result status.
- Key rotation path that doesn't require a deploy.
FAQ
Does every MCP server need OAuth? No. A local stdio server that never leaves your machine gets its security from the OS process boundary, not from OAuth. OAuth becomes necessary once the server is reachable over a network by more than one trusted party, especially if different callers should have different permissions.
Can I just use an API key instead of OAuth for a remote MCP server? Yes, for a small number of known internal clients where everyone with the key should have the same permissions. It stops working once you need per-user scoping, audit trails tied to individual identities, or the ability to revoke one caller's access without breaking everyone else's.
Where should the MCP server store the upstream API credentials it uses? In environment variables or a secrets manager on the server side, injected at process start, never passed through as tool call arguments and never returned in tool output or error text where the model (and anything logging its output) can see them.
How do scopes map to MCP tools? There's no built-in MCP mechanism that auto-maps OAuth scopes to specific tools, you implement that check yourself inside each tool handler by inspecting the claims on the validated token. Treat it the same way you'd treat authorization checks in any API endpoint.
What happens if a prompt injection tricks the model into calling a tool it shouldn't? Authentication limits what's possible, not what the model attempts. The model can still try to call any tool it has access to. The defense is scoping the token tightly enough that even a successful injection can only reach the narrow set of actions that scope allows, plus keeping destructive tools (deletes, payments, permission changes) behind a stricter scope or a human confirmation step regardless of token validity.
Is Dynamic Client Registration required for MCP OAuth? It's part of the recommended flow because it lets any compliant MCP client connect without a manual "register an OAuth app" step first, which matters when your server might be used by clients you don't control. If you're only ever going to be called by one internal client you built yourself, you can skip DCR and pre-register that one client instead, it's a simplification, not a spec violation.
Should refresh tokens ever be shared between multiple MCP servers? No. Each MCP server (or more precisely, each resource this server represents) should get its own token audience. Sharing a refresh token across servers means a leak in one place compromises access everywhere that token is valid.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.
Related reading