MCP server security is the problem nobody wants to own. The platform team shipped the servers. The AI team wired up the tools. Security got a five-minute demo and signed off on a PoC that quietly became production six weeks later.
Now there are roughly 10,000 MCP servers estimated in the wild — across enterprise deployments, internal developer tooling, and third-party integrations. RSA researchers called it a "ticking clock" in early 2026[5]. The 2026 MCP roadmap explicitly flags authentication gaps as a priority. But roadmaps don't patch your running server.
This guide is for the platform engineer or security lead who inherited an MCP deployment they didn't design. It assumes you already know what MCP is and focuses on the four places where most deployments are currently exposed: static client secrets, missing identity propagation, unguarded gateways, and error messages that leak internals.
The Static Secret Problem
Why the auth model most MCP servers ship with is a credential waiting to be exfiltrated.
The MCP spec's original transport layer assumed a trusted execution environment — typically a local process communicating 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's what that looks like in practice across TypeScript MCP servers today:
mcp-server-bad.ts// DANGER: Static client secret — what most MCP servers look like today
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();
// Middleware: single static token check — no user context, 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;
}
// Problem 1: No user identity attached — all requests look identical
// Problem 2: Secret never expires, rotates only on manual intervention
// Problem 3: Any client with the secret has full tool access
// Problem 4: Secret likely lives in a .env committed to git at some point
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);The Auth Architecture That Actually Works
Token-based auth with your SSO provider as the authority — not a secret you manage.
The key shift: the MCP server stops being an auth authority and becomes an auth consumer. Your identity provider (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 any crypto itself.
This pattern gives you automatic token expiry, centralized access control, audit trails, and MFA enforcement for free — because all of that lives in the IdP you already run.
Replacing Static Secrets with OIDC Token Validation
Working TypeScript for an MCP server that validates JWT access tokens from your identity provider.
mcp-auth-middleware.ts// MCP server with OIDC JWT validation — no static secrets
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { Request, Response, NextFunction } from 'express';
interface UserContext {
sub: string; // stable user identifier
email: string;
groups: string[]; // from IdP group claims
scope: string; // granted OAuth scopes
}
// Extend Express request to carry verified user context
declare global {
namespace Express {
interface Request {
user?: UserContext;
}
}
}
// Load JWKS from your IdP — cached automatically by jose, refreshes on rotation
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
});
// Attach verified user context — downstream tools can trust this
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) {
// Log the real error internally, return generic message to caller
console.error('Token validation failed:', err instanceof Error ? err.message : err);
res.status(401).json({
error: 'invalid_token',
error_description: 'Token validation failed'
// Note: never forward err.message — it can expose IdP internals
});
}
}
// Scope enforcement — call this per-tool, not per-request
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: Making User Context Flow Through Tool Calls
Without identity propagation, your MCP server is a privilege escalation vector — tools run as the server, not the user.
Authentication at the transport layer answers "who connected." Identity propagation answers "who should this tool act as." They are different problems and most MCP deployments only solve the first one.
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. A sales rep asking a tool to pull customer data gets the same result as a sysadmin. That's not a permissions model, that's a permission bypass[1].
The fix is passing the verified user context forward as an impersonation credential or a forwarded token:
mcp-identity-propagation.ts// Identity propagation — passing user context through MCP tool execution
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { Request } from 'express';
// Async context carrier — avoids threading req through every function
import { AsyncLocalStorage } from 'node:async_hooks';
interface RequestContext {
userId: string;
userEmail: string;
userGroups: string[];
originalToken: string; // forward to downstream services that accept it
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
// In your SSE handler, seed the async context from the verified request
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);
}
// Tool registration — each tool pulls user context from the 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 to the downstream API
// The downstream service enforces its own 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) {
// Return structured error — do not expose upstream response body
return {
content: [{
type: 'text',
text: JSON.stringify({
error: 'upstream_error',
status: response.status
// Note: never include response.statusText or body — they can 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: What the MCP Server Shouldn't Have to Handle
Push rate limiting, IP allowlisting, and request size enforcement to the gateway layer — not application code.
The MCP server is a tool execution runtime, not a security appliance. Expecting it to handle rate limiting, DDoS protection, and geographic restrictions is the wrong architecture. Those belong at the gateway layer — whether that's Kong, AWS API Gateway, Nginx, or Cloudflare.
Here's what the gateway needs to enforce before a request reaches your MCP server:
| Control | Why It Matters for MCP | Gateway Implementation |
|---|---|---|
| JWT validation at edge | Stops unauthenticated requests before they reach the server process | Use the IdP JWKS endpoint — most gateways have native OIDC plugins |
| Per-user rate limiting | Prevents a single compromised token from hammering downstream tools | Key by JWT `sub` claim, not IP — rate limit the identity, not the network address |
| Request body size cap | MCP tool arguments can carry large payloads — limit to 1MB unless justified | Nginx: `client_max_body_size 1m`; Kong: request-size-limiting plugin |
| Tool allowlist by scope | Prevents tokens scoped for read-only tools from calling write tools | Route matching + JWT scope claim validation at gateway level |
| Audit log forwarding | Every tool call should produce an immutable log entry with user, tool, and args | Forward to SIEM before the request hits the server — not after |
| TLS termination + HSTS | MCP SSE connections over plain HTTP are trivially interceptable | Enforce TLS 1.2+ minimum; set HSTS header with 1-year max-age |
Structured Error Semantics: Stop Leaking Internals
MCP error responses are often verbose. That verbosity is a reconnaissance gift for anyone probing your deployment.
MCP error handling defaults to transparency — helpful during development, dangerous in production. Stack traces, database error messages, upstream API responses, and internal service names all end up in tool call responses if you don't explicitly sanitize them.
The MCP protocol defines a structured error format in the JSON-RPC layer. Use it consistently:
mcp-error-semantics.ts// Structured error handling — safe for production MCP servers
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
// Error category map — internal codes map to safe external codes
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'
}
};
// Logger interface — replace with your production logger
interface Logger {
error(msg: string, ctx: Record<string, unknown>): void;
}
export function sanitizeError(
err: unknown,
toolName: string,
userId: string,
logger: Logger
): McpError {
// Log the real error internally — include context for debugging
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 goes to your log aggregator, never to the caller
stack: err instanceof Error ? err.stack : undefined
});
// Map to safe external error
const safeError = SAFE_ERROR_CODES[errorCode];
if (safeError) {
return new McpError(safeError.code, safeError.message);
}
// Default: return generic message for anything unrecognized
// Do NOT include internalMessage in the returned error
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);
// }MCP Server Security Hardening Checklist
Run through this before your next production deployment — or your next security review.
MCP Server Hardening Checklist
Replace static client secrets with JWT validation against your IdP's JWKS endpoint
Configure token issuer and audience validation — not just signature verification
Attach verified user identity (sub, email, groups) to every tool execution context
Forward user tokens or impersonation credentials to downstream services — never use the server's service account for user-scoped operations
Move JWT validation, rate limiting, and audit logging to the gateway layer
Rate limit by JWT
subclaim — not by IP addressSet explicit SSE idle timeout at the gateway (5-30 minutes depending on workflow length)
Cap request body size at the gateway (1MB default, justify exceptions)
Sanitize all error messages — internal errors log internally, external errors are generic
Enforce TLS 1.2+ with HSTS on all MCP endpoints
Implement tool-level scope enforcement — not just connection-level auth
Audit log every tool call: user, tool name, args (redacted), timestamp, outcome
Rotate any existing static secrets immediately and document rotation schedule for service accounts
What You're Actually Protecting Against
The realistic attack vectors for production MCP deployments in 2026.
Static secret in .env — rotated never, leaked eventually
All clients share one identity — no audit trail per user
Tool calls run as service account — full upstream access for everyone
Error messages expose stack traces, DB errors, internal hostnames
Long-lived SSE connections with no timeout — zombie sessions accumulate
Rate limits missing — one token can hammer every tool
No scope enforcement — read token can call write tools
Short-lived JWT tokens from IdP — auto-expired, MFA-enforced
Every tool call bound to a verified user identity — full audit trail
Tool calls forward user credentials — downstream access matches user permissions
Sanitized error codes only — internal context logged separately
Gateway enforces idle timeout — stale connections closed automatically
Per-user rate limiting by sub claim — protects server and downstream APIs
Scope-gated tool routing — tokens can only reach tools they're scoped for
Migrating an Existing Deployment Without Breaking Clients
How to move from static secrets to token-based auth without a flag-day cutover.
- 1
Audit what's connecting and how
Before changing auth, map every client that connects to your MCP server. Log the Authorization header value (hashed, not plain) and client User-Agent for two weeks. You need to know what will break before you break it.
- 2
Register your MCP server as an OIDC resource server
In your IdP (Okta, Azure AD, Google Workspace), create an application registration for the MCP server. Define the scopes that map to tool categories — e.g.,
mcp:readfor query tools,mcp:writefor mutation tools. This is the foundation; everything else depends on it. - 3
Deploy dual-auth middleware with a feature flag
Accept both the old static secret and valid JWTs simultaneously during the migration window. Clients can migrate on their own schedule without a hard cutover. Set a removal date — three months is realistic for internal tooling.
- 4
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, since users can test interactively.
- 5
Migrate service clients with client credentials flow
Automated pipelines and CI systems that call 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, not .env files), and its own scope grants.
- 6
Kill the static secret
Once all clients are migrated and dual-auth logging shows zero static-secret requests for a week, remove the fallback path. Rotate the old secret out of all environment configs and secret stores. Document the removal in your runbook.
The MCP spec doesn't mandate OAuth — do I really need this?
The spec doesn't 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 without auth because it's 'only internal.' Static secrets work fine for a local developer tool. They don't belong in multi-user deployments that touch real data and real upstream systems.
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's no network surface. The concerns in this guide apply specifically to HTTP/SSE transports used for multi-client or remote deployments. If your deployment has moved from stdio to HTTP for scale, read this as 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 above) buys you a migration window without a flag-day cutover. Set a hard deadline — three months is reasonable — and communicate it clearly. If a client can't implement OAuth in that window, it shouldn't have access to production tools that touch sensitive data.
What about tool-level access control beyond OAuth scopes?
OAuth scopes handle coarse-grained access — read vs. write, tool category vs. tool category. For fine-grained access (specific customers a user can query, specific repos a token can access), you need attribute-based access control in the tool implementation itself. The user context flowing through AsyncLocalStorage makes this possible — each tool can check user groups and enforce row-level rules against its own data source.
We inherit user tokens from upstream and forward them. Is that safe?
Token forwarding is the right pattern for maintaining user identity through tool chains, but only if the downstream services validate those tokens against the same IdP. Don't forward tokens to services that don't validate them — that's just moving the unauthenticated call downstream. Also verify that your tokens don't grant broader scope than intended at each hop.
How do we handle MCP servers from third-party vendors?
Third-party MCP servers you don't 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 if the vendor's server itself does nothing with user identity. Treat third-party MCP servers with the same skepticism as any third-party SaaS integration — audit their data handling practices before connecting them to internal systems.
MCP server security doesn't require a rewrite. The protocol itself is sound — it just shipped without prescribing auth patterns because that's 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, and structured error semantics — cover the vast majority of the attack surface for a typical enterprise MCP deployment. None of them require changing the MCP protocol or waiting for the roadmap to catch up.
The teams that close these gaps now will spend their next security review explaining a well-reasoned architecture. The teams that don't will spend it explaining an incident.
We ran the migration from static secrets to JWT in three weeks. The hardest part wasn't the code — it was discovering that four different teams had been sharing the same MCP secret in their environment configs for months. The audit phase alone was worth it.
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)↩