MCP server security is the problem nobody owns. The platform team shipped the servers. The AI team wired up the tools. Security got a five-minute demo, signed off on a PoC, and that PoC quietly became production six weeks later. Nobody explicitly chose this configuration. It is the default state of any system without an owner.
There are roughly 10,000 MCP servers running in the wild as of early 2026 — across enterprise deployments, internal developer tooling, and third-party integrations. RSA researchers called it a "ticking clock"[5]. The 2026 MCP roadmap explicitly flags authentication gaps as a priority. Roadmaps do not patch your running server.
This guide is for the platform engineer or security lead who inherited a deployment they did not design. It assumes you know what MCP is. It focuses on the four places production deployments are currently exposed: static client secrets, missing identity propagation, unguarded gateways, and error messages that hand reconnaissance to whoever asks.
The last audit we ran on an inherited deployment found the static secret committed to git — in .env.example, four months earlier. GitHub search surfaced it in under 30 seconds. Three production services were using that secret. None had rate limiting. The PoC-to-production gap had been six weeks. Audit before you change anything. The findings are usually worse than the architecture diagram suggests.
The Static Secret Is Not Auth. It Is a Credential Waiting to Leak.
The auth model most MCP servers shipped with assumes a trust boundary that no longer exists.
The original MCP transport assumed a trusted execution environment — a local subprocess talking over stdio. When teams moved to HTTP/SSE transports for multi-client deployments, they needed some form of auth and reached for the path of least resistance: a shared static secret in an Authorization header.
Here is what that pattern looks like in TypeScript MCP servers in the field today:
mcp-server-bad.ts// Failure mode: static client secret. The default pattern in most MCP tutorials.
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import express from 'express';
const STATIC_SECRET = process.env.MCP_SECRET ?? 'my-hardcoded-secret-123';
const app = express();
// One token check. No identity. No expiry. No rotation.
app.use((req, res, next) => {
const auth = req.headers['authorization'];
if (auth !== `Bearer ${STATIC_SECRET}`) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
// Failure 1: no user identity attached — every request looks identical in logs
// Failure 2: secret never expires; rotation is a manual incident response
// Failure 3: any client holding the secret has full tool access
// Failure 4: the secret is already in someone's .env, probably in git
next();
});
const server = new Server(
{ name: 'internal-tools', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/message', res);
await server.connect(transport);
});
app.listen(3000);Stop Being an Auth Authority. Start Being an Auth Consumer.
The IdP issues. The gateway validates. The MCP server trusts the verified context — and never the request body.
The structural shift: the MCP server stops being an auth authority and becomes an auth consumer. Your IdP — Okta, Azure AD, Google Workspace, Auth0, pick one — issues short-lived access tokens[3]. The gateway validates them against the provider's JWKS endpoint. The MCP server receives a verified user context it can trust without doing crypto itself.
What you get for free: automatic token expiry, centralized access control, audit trails, MFA enforcement. Not because you built it. Because it lives in the IdP your company already runs.
Replacing the Static Secret With Verified JWTs
Working TypeScript. JWT validated against the IdP. Issuer and audience checked, not just signature.
mcp-auth-middleware.ts// MCP server middleware. JWT validated against the IdP — no static secrets.
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { Request, Response, NextFunction } from 'express';
interface UserContext {
sub: string; // stable user identifier from the IdP
email: string;
groups: string[]; // group claims — the basis for downstream authz
scope: string; // OAuth scopes — the basis for tool routing
}
// Carry verified context on the request. Tools trust this. Nothing else.
declare global {
namespace Express {
interface Request {
user?: UserContext;
}
}
}
// JWKS cached by jose. Refreshes on key rotation. No manual handling.
const JWKS = createRemoteJWKSet(
new URL(process.env.OIDC_JWKS_URI!) // e.g. https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys
);
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({
error: 'missing_token',
error_description: 'Authorization header with Bearer token required'
});
return;
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.OIDC_ISSUER, // e.g. https://login.microsoftonline.com/{tenant}/v2.0
audience: process.env.OIDC_AUDIENCE // your MCP server's registered client ID
});
// Verified context. Downstream tools read from req.user, not the request body.
req.user = {
sub: payload.sub as string,
email: payload.email as string,
groups: (payload.groups as string[]) ?? [],
scope: (payload.scp ?? payload.scope ?? '') as string
};
next();
} catch (err: unknown) {
// Real error to the log. Generic error to the caller.
console.error('Token validation failed:', err instanceof Error ? err.message : err);
res.status(401).json({
error: 'invalid_token',
error_description: 'Token validation failed'
// Never forward err.message — it leaks IdP internals.
});
}
}
// Scope check per tool, not per request. Connection-level auth is not enough.
export function requireScope(requiredScope: string) {
return (req: Request, res: Response, next: NextFunction): void => {
const grantedScopes = req.user?.scope?.split(' ') ?? [];
if (!grantedScopes.includes(requiredScope)) {
res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scope: ${requiredScope}`
});
return;
}
next();
};
}Identity Propagation: Without It, Every Tool Is a Privilege Escalator
Authentication answers who connected. Propagation answers who the tool acts as. They are different problems and most deployments only solve the first.
Authentication at the transport layer answers "who connected." Identity propagation answers "who should this tool act as." Different problems. Most MCP deployments solve the first and ignore the second.
Consider a tool that queries your data warehouse or calls an internal API. If the MCP server makes that downstream call using its own service account, the tool inherits service-level access — not the connecting user's access. The sales rep asking for customer data and the sysadmin asking for customer data get the same result. That is not a permissions model. That is a permission bypass[1].
The fix is forwarding the verified user context as an impersonation credential or a forwarded token. The downstream system enforces row-level access against the user, not against the MCP server's principal:
mcp-identity-propagation.ts// Identity propagation. Verified user context flows through every tool call.
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { Request } from 'express';
// AsyncLocalStorage carries the context. No threading req through every function.
import { AsyncLocalStorage } from 'node:async_hooks';
interface RequestContext {
userId: string;
userEmail: string;
userGroups: string[];
originalToken: string; // forwarded to downstream services that accept it
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
// Seed the async store from the verified request. Tools read from the store.
export function withUserContext<T>(req: Request, fn: () => Promise<T>): Promise<T> {
const ctx: RequestContext = {
userId: req.user!.sub,
userEmail: req.user!.email,
userGroups: req.user!.groups,
originalToken: req.headers.authorization!.slice(7)
};
return requestContext.run(ctx, fn);
}
// One handler. Every tool pulls user context from the same store.
const server = new Server(
{ name: 'internal-tools', version: '2.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const ctx = requestContext.getStore();
if (!ctx) {
throw new Error('No request context — tool called outside authenticated scope');
}
const { name, arguments: args } = request.params;
if (name === 'query-customer-data') {
// Forward the user's own token. Downstream enforces row-level access.
const response = await fetch('https://internal-api.example.com/customers', {
headers: {
'Authorization': `Bearer ${ctx.originalToken}`,
'X-Forwarded-User': ctx.userId,
'X-Forwarded-Email': ctx.userEmail
}
});
if (!response.ok) {
// Structured error. Upstream response body never leaves this function.
return {
content: [{
type: 'text',
text: JSON.stringify({
error: 'upstream_error',
status: response.status
// Never include statusText or body — they leak internals.
})
}],
isError: true
};
}
const data = await response.json();
return {
content: [{ type: 'text', text: JSON.stringify(data) }]
};
}
throw new Error(`Unknown tool: ${name}`);
});Gateway Hardening: Stop Asking the MCP Server to Be a Security Appliance
Rate limits, IP allowlisting, request size, TLS — these belong at the gateway. Application code is the wrong layer.
The MCP server is a tool execution runtime. It is not a security appliance. Asking it to handle rate limiting, DDoS protection, and geographic restrictions is the wrong layer. Those controls belong at the gateway — Kong, AWS API Gateway, Nginx, Cloudflare, take your pick.
Here is the minimum the gateway must enforce before a request ever touches your MCP process:
| Control | Failure Mode It Stops | Gateway Implementation |
|---|---|---|
| JWT validation at edge | Unauthenticated requests reaching the server process at all | IdP JWKS endpoint — most gateways ship a native OIDC plugin |
| Per-user rate limiting | One compromised token hammering downstream tools until billing notices | Key by JWT sub claim, not IP — the identity is the unit of enforcement |
| Request body size cap | Tool arguments smuggling huge payloads through unbounded readers | Nginx: client_max_body_size 1m; Kong: request-size-limiting plugin |
| Tool allowlist by scope | Read-scoped tokens reaching write tools because routing trusts the prompt | Route matching plus JWT scope claim validation at gateway level |
| Audit log forwarding | Tool calls that left no trace because the SIEM hookup was post-hoc | Forward to SIEM before the request hits the server, not after |
| TLS termination plus HSTS | Plain-HTTP SSE traffic intercepted on a coffee-shop network | Enforce TLS 1.2+ minimum; HSTS with one-year max-age |
Error Messages Are a Reconnaissance Channel
Verbose errors helpful in development become a free internal-system tour in production.
MCP error handling defaults to transparency. Helpful in development. Dangerous in production. Stack traces, database error messages, upstream API responses, internal service names — all of it ends up in tool call responses if you do not explicitly sanitize.
The MCP protocol defines a structured error format in the JSON-RPC layer. Use it. Map every internal error code to a safe external one, log the real one to your aggregator, and never let the original message reach the caller:
mcp-error-semantics.ts// Error sanitization. Internal context to the log. Generic codes to the caller.
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
// Internal code -> safe external code. Anything not in this map gets the default.
const SAFE_ERROR_CODES: Record<string, { code: ErrorCode; message: string }> = {
'ECONNREFUSED': {
code: ErrorCode.InternalError,
message: 'Upstream service unavailable'
},
'ETIMEDOUT': {
code: ErrorCode.InternalError,
message: 'Request timed out'
},
'INVALID_GRANT': {
code: ErrorCode.InvalidRequest,
message: 'Authentication token is no longer valid'
},
'PERMISSION_DENIED': {
code: ErrorCode.InvalidRequest,
message: 'Insufficient permissions for this operation'
}
};
interface Logger {
error(msg: string, ctx: Record<string, unknown>): void;
}
export function sanitizeError(
err: unknown,
toolName: string,
userId: string,
logger: Logger
): McpError {
// The real error goes to the aggregator. Always.
const internalMessage = err instanceof Error ? err.message : String(err);
const errorCode = (err as NodeJS.ErrnoException).code ?? 'UNKNOWN';
logger.error('Tool execution error', {
tool: toolName,
userId,
errorCode,
// Stack trace to the log. Never to the caller.
stack: err instanceof Error ? err.stack : undefined
});
const safeError = SAFE_ERROR_CODES[errorCode];
if (safeError) {
return new McpError(safeError.code, safeError.message);
}
// Default: generic message, nothing internal in the wire response.
return new McpError(
ErrorCode.InternalError,
'An unexpected error occurred. Reference your support team with the request timestamp.'
);
}
// Usage in a tool handler:
// try {
// const result = await callDownstreamService(args);
// return { content: [{ type: 'text', text: JSON.stringify(result) }] };
// } catch (err) {
// throw sanitizeError(err, 'tool-name', ctx.userId, logger);
// }The Pre-Deployment Audit Nobody Will Run For You
Each item is a verifiable state. If you cannot tick it, the next security review will.
MCP Server Hardening Checklist
Static client secrets replaced with JWT validation against the IdP's JWKS endpoint
Token issuer and audience validated — not just the signature
Verified user identity (sub, email, groups) attached to every tool execution context
User tokens or impersonation credentials forwarded to downstream services — never the server's service account for user-scoped operations
JWT validation, rate limiting, and audit logging moved to the gateway layer
Rate limiting keyed by JWT
subclaim — not IP addressExplicit SSE idle timeout set at the gateway (5–30 minutes by workflow length)
Request body size capped at the gateway (1MB default; exceptions justified, not assumed)
Error messages sanitized — internal errors logged internally, external errors generic
TLS 1.2+ enforced with HSTS on every MCP endpoint
Tool-level scope enforcement in place — connection-level auth is not enough
Every tool call audit-logged: user, tool, args (redacted), timestamp, outcome
Existing static secrets rotated immediately; rotation schedule documented for service accounts
The Threat Model Is Not Theoretical
Static-secret deployments versus hardened deployments — the same incident scenarios, different outcomes.
Static secret in .env. Rotated never. Leaks eventually.
Every client shares one identity. No per-user audit trail exists.
Tool calls run as the service account. Every caller gets the upstream blast radius.
Errors expose stack traces, DB messages, internal hostnames.
Long-lived SSE with no timeout. Zombie sessions accumulate until restart.
No rate limit. One token can hammer every tool until billing notices.
No scope enforcement. A read token reaches write tools.
Short-lived JWTs from the IdP. Auto-expire. MFA enforced upstream.
Every tool call bound to a verified user identity. The audit trail writes itself.
Tools forward user credentials. Downstream access matches the user, not the server.
Sanitized error codes on the wire. Internal context lives in the log aggregator.
Gateway closes idle connections automatically. Stale sessions die before they matter.
Per-user rate limiting by sub claim. Server and downstream APIs both protected.
Scope-gated routing. Tokens reach only the tools their scope permits.
Migration Without a Flag-Day Cutover
Dual-auth middleware buys you a window. Use it. Then close it on a date you publish in advance.
- [01]
Audit what is connecting and how
Before touching auth, map every client connecting to your MCP server. Log the Authorization header value (hashed, not plain) and the User-Agent for two weeks. You need to know what will break before you break it. Skipping this step is how migrations cause incidents that look like the auth change but are really an unowned client nobody knew existed.
- [02]
Register the MCP server as an OIDC resource server
In your IdP — Okta, Azure AD, Google Workspace — create an application registration for the MCP server. Define scopes that map to tool categories:
mcp:readfor query tools,mcp:writefor mutation tools. Everything else depends on this. Wrong scopes here means rewriting the routing layer later. - [03]
Deploy dual-auth middleware behind a feature flag
Accept both the old static secret and valid JWTs simultaneously during the migration window. Clients migrate on their own schedule. No hard cutover. Set a removal date in advance — three months is realistic for internal tooling. The deadline is the enforcement; the dual-path is the runway.
- [04]
Migrate interactive clients first
Human-facing MCP clients — Claude Desktop, VS Code extensions, internal chat integrations — benefit most from SSO. Users authenticate with their existing corporate credentials. Ship OAuth PKCE flows for these clients first. Users can test interactively, surface bugs early, and absorb the change without a runbook update.
- [05]
Migrate service clients with the client credentials flow
Automated pipelines and CI systems calling MCP tools programmatically should use OAuth 2.0 client credentials — not the authorization code flow. Each service gets its own client ID and client secret stored in your secrets manager (Vault, AWS Secrets Manager — never
.env), and its own scope grants. One service compromised does not compromise the others. - [06]
Kill the static secret
Once dual-auth logging shows zero static-secret requests for a week, remove the fallback path. Rotate the old secret out of every environment config and secret store. Document the removal in the runbook so the next on-call does not look for it during an incident. The migration is not done until the old code path is gone.
The MCP spec doesn't mandate OAuth — do I really need this?
The spec does not mandate it because the spec describes the protocol, not your security posture. Running MCP in production without scoped tokens is the same logic as running an internal REST API with no auth because it is "only internal." Static secrets are fine for a local developer tool. They do not belong in multi-user deployments touching real data and real upstream systems. The spec is silent because the spec is not your auditor.
We use stdio transport, not HTTP/SSE. Does this apply?
For local stdio deployments where the MCP server runs as a subprocess of a single trusted client, static secrets are mostly a non-issue — there is no network surface. The concerns here apply to HTTP/SSE transports used for multi-client or remote deployments. If your deployment moved from stdio to HTTP for scale and nobody re-evaluated auth at the same time, this is the compliance gap you need to close.
How do we handle MCP clients that don't support OAuth yet?
Dual-auth middleware (step 3 of the migration path) buys you a window without a hard cutover. Set the deadline at three months and communicate it. If a client cannot implement OAuth in that window, it does not have access to production tools touching sensitive data. The deadline is the enforcement. Everything before it is runway.
What about tool-level access control beyond OAuth scopes?
OAuth scopes handle coarse-grained access — read versus write, tool category versus tool category. For fine-grained access — specific customers a user can query, specific repos a token can reach — you need attribute-based access control inside the tool itself. The user context flowing through AsyncLocalStorage makes this possible: each tool checks user groups and enforces row-level rules against its own data source. The scope routes the request. The tool decides what the user is allowed to see.
We inherit user tokens from upstream and forward them. Is that safe?
Token forwarding is the right pattern for preserving identity through tool chains — but only if downstream services validate against the same IdP. Forwarding a token to a service that does not validate it just moves the unauthenticated call one hop downstream. Verify also that the token's scope is not broader than what the next service expects to receive. Both ends of the hop need to enforce. If only one side checks, the chain has a hole.
How do we handle MCP servers from third-party vendors?
Third-party MCP servers you do not control are the hardest case. At minimum: route them through your gateway so you control the auth layer, enforce rate limiting, and capture audit logs even when the vendor's server itself does nothing with user identity. Treat them with the skepticism you would apply to any third-party SaaS integration. Three questions to ask the vendor before connecting: which data from tool calls do you retain and for how long, does your server support OIDC or only static secrets, and do you have a SOC 2 Type II report covering the MCP deployment? If they cannot answer those three, do not route internal data through their server.
MCP server security does not require a rewrite. The protocol is sound. It shipped without prescribing auth patterns because that is the deployer's job. The problem is that most deployers copy the quickstart, ship it, and move on.
The four controls in this guide — token-based auth, identity propagation, gateway hardening, structured error semantics — close the bulk of the attack surface for a typical enterprise MCP deployment. None of them require changing the protocol or waiting for the roadmap to catch up.
The teams that close these gaps now spend their next security review explaining a well-reasoned architecture. The teams that do not spend it explaining an incident.
On MCP specification versions
The MCP specification has evolved rapidly since its November 2024 GA. The authentication patterns in this guide are compatible with the 2025-11-05 specification version and align with the authentication RFC proposals in the MCP working group as of early 2026. Verify against the current spec at modelcontextprotocol.io before implementation.
- [1]Model Context Protocol Specification (2025-11-05)(spec.modelcontextprotocol.io)↩
- [2]Model Context Protocol: Transport Concepts(modelcontextprotocol.io)↩
- [3]OAuth 2.0 Authorization Framework(oauth.net)↩
- [4]OpenID Connect Core 1.0 Specification(openid.net)↩
- [5]RSA Research: MCP Security Threat Assessment(rsa.com)↩