Skip to content
AI Native Builders

How to Harden Your MCP Server Before It Becomes a Security Incident

A practitioner's guide to MCP server security: replacing static client secrets with OAuth 2.0/OIDC, propagating user identity through tool calls, hardening gateway behavior, and fixing structured error semantics before an auditor finds them first.

Governance & AdoptionadvancedMar 22, 20264 min read
A server vault with its door wide open while a robot casually walks in and out, as a security guard sleeps nearby — illustrating unguarded MCP server accessMost production MCP deployments are running with the door wide open.

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.

10,000+
MCP servers estimated in production (2026) — based on publicly indexed deployments; actual count is likely higher
Very few
production MCP deployments with documented SSO/OIDC integration patterns — exact count unknown but consistently low in practitioner audits
CVE-2025-*
MCP-related security advisories filed since protocol GA — numbers are growing as adoption increases
~90 days
typical observed window between PoC deployment and quiet production promotion — your organization's timeline may differ

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.

MCP Auth Flow with SSO Integration
Token-based auth replaces static client secrets. The identity provider is the authority; the MCP server only validates, never issues.

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:

ControlWhy It Matters for MCPGateway Implementation
JWT validation at edgeStops unauthenticated requests before they reach the server processUse the IdP JWKS endpoint — most gateways have native OIDC plugins
Per-user rate limitingPrevents a single compromised token from hammering downstream toolsKey by JWT `sub` claim, not IP — rate limit the identity, not the network address
Request body size capMCP tool arguments can carry large payloads — limit to 1MB unless justifiedNginx: `client_max_body_size 1m`; Kong: request-size-limiting plugin
Tool allowlist by scopePrevents tokens scoped for read-only tools from calling write toolsRoute matching + JWT scope claim validation at gateway level
Audit log forwardingEvery tool call should produce an immutable log entry with user, tool, and argsForward to SIEM before the request hits the server — not after
TLS termination + HSTSMCP SSE connections over plain HTTP are trivially interceptableEnforce 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 sub claim — not by IP address

  • Set 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.

Unguarded MCP Deployment
  • 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

Hardened MCP Deployment
  • 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. 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. 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:read for query tools, mcp:write for mutation tools. This is the foundation; everything else depends on it.

  3. 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. 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. 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. 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.

Auto-expiry
Tokens expire — no permanent credentials in the wild
Full audit trail
Every tool call tied to a real user identity
Scope enforcement
Tools accessible only to tokens scoped for them
IdP integration
MFA, group policies, and access reviews apply automatically

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.

Platform Security Lead, Series B SaaS, 120-person engineering org

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.

Key terms in this piece
MCP server securityModel Context Protocol authenticationMCP OAuth OIDCMCP identity propagationMCP gateway hardeningstatic client secret riskenterprise MCP security
Sources
  1. [1]Model Context Protocol Specification (2025-11-05)(spec.modelcontextprotocol.io)
  2. [2]Model Context Protocol: Transport Concepts(modelcontextprotocol.io)
  3. [3]OAuth 2.0 Authorization Framework(oauth.net)
  4. [4]OpenID Connect Core 1.0 Specification(openid.net)
  5. [5]RSA Research: MCP Security Threat Assessment(rsa.com)
Share this article