P430 min

Authentication & Security

OAuth 2.1, PKCE, credential encryption, input sanitization, and rate limiting for production MCP servers.

On This Page

Key Concepts

  • OAuth 2.1 + PKCE for MCP server authentication
  • Credential storage with envelope encryption
  • Input sanitization to prevent injection attacks
  • Rate limiting per-user and per-tool
  • Transport security for HTTP-based MCP
  • Security audit checklist for production servers

An MCP server with tools that modify data, call APIs, or access sensitive information must authenticate users and secure its operations. The MCP specification includes an authentication framework based on OAuth 2.1, and this module shows you how to implement it correctly.

Security in MCP is not optional polish. A server without auth lets anyone with network access call your tools. A server without input sanitization is vulnerable to prompt injection through tool arguments.

OAuth 2.1 with PKCE

The MCP spec recommends OAuth 2.1 with PKCE (Proof Key for Code Exchange) for authenticating clients. This is the same flow used by mobile apps and SPAs — no client secret needed.

// The OAuth flow for MCP:
// 1. Client discovers auth requirements via /.well-known/oauth-authorization-server
// 2. Client generates a code_verifier and code_challenge (PKCE)
// 3. Client opens browser to authorization endpoint
// 4. User authenticates and authorizes
// 5. Authorization server redirects with auth code
// 6. Client exchanges code + code_verifier for access token
// 7. Client includes token in MCP requests

// Server-side: metadata endpoint
app.get("/.well-known/oauth-authorization-server", (req, res) => {
  res.json({
    issuer: "https://your-server.com",
    authorization_endpoint: "https://your-server.com/authorize",
    token_endpoint: "https://your-server.com/token",
    registration_endpoint: "https://your-server.com/register",
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256"],
    token_endpoint_auth_methods_supported: ["none"],
  });
});

Key points about MCP OAuth:

  • PKCE is mandatory — no client secrets, code challenge required
  • Dynamic client registration allows new clients without pre-configuration
  • Token refresh keeps long-running sessions alive
  • Scopes control which tools and resources a client can access
// Middleware to verify tokens on incoming MCP requests
function authMiddleware(allowedScopes: string[]) {
  return async (req: Request) => {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader?.startsWith("Bearer ")) {
      throw new McpError(
        ErrorCode.InvalidRequest,
        "Missing or invalid Authorization header"
      );
    }

    const token = authHeader.slice(7);
    const payload = await verifyToken(token);

    // Check scopes
    const tokenScopes = payload.scope?.split(" ") || [];
    const hasScope = allowedScopes.some(s => tokenScopes.includes(s));
    if (!hasScope) {
      throw new McpError(
        ErrorCode.InvalidRequest,
        `Insufficient scope. Required: ${allowedScopes.join(" or ")}`
      );
    }

    return payload;
  };
}

Credential Management

Your MCP server likely needs credentials to access external services — API keys, database passwords, OAuth tokens. How you store and manage these credentials determines your security posture.

// BAD: Credentials in code or plain config
const API_KEY = "sk-abc123...";  // Leaked if repo is public
const config = JSON.parse(fs.readFileSync("config.json"));  // Plain text on disk

// GOOD: Environment variables (minimum viable security)
const API_KEY = process.env.WEATHER_API_KEY;
if (!API_KEY) throw new Error("WEATHER_API_KEY not set");

// BETTER: Envelope encryption for stored credentials
// 1. Generate a data encryption key (DEK) for each credential
// 2. Encrypt the DEK with a key encryption key (KEK) from a vault
// 3. Store the encrypted DEK alongside the encrypted credential
// 4. At runtime: decrypt DEK with KEK, decrypt credential with DEK

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

function encryptCredential(plaintext: string, kek: Buffer): {
  encrypted: string;
  iv: string;
  encryptedDek: string;
  dekIv: string;
} {
  // Generate a random DEK
  const dek = randomBytes(32);
  const dekIv = randomBytes(16);

  // Encrypt the credential with the DEK
  const cipher = createCipheriv("aes-256-cbc", dek, dekIv);
  const encrypted = Buffer.concat([
    cipher.update(plaintext, "utf8"),
    cipher.final(),
  ]).toString("base64");

  // Encrypt the DEK with the KEK
  const kekIv = randomBytes(16);
  const kekCipher = createCipheriv("aes-256-cbc", kek, kekIv);
  const encryptedDek = Buffer.concat([
    kekCipher.update(dek),
    kekCipher.final(),
  ]).toString("base64");

  return {
    encrypted,
    iv: dekIv.toString("base64"),
    encryptedDek,
    dekIv: kekIv.toString("base64"),
  };
}

Credential hierarchy:

  1. Environment variables — minimum bar, works for single-server setups
  2. Encrypted config files — envelope encryption, keys from a vault or KMS
  3. Secret manager integration — AWS Secrets Manager, HashiCorp Vault, etc.

Input Sanitization

MCP tool inputs come from the AI model, which processes user messages. This creates a prompt injection vector: a user could craft a message that causes the model to pass malicious input to your tool.

// VULNERABLE: SQL injection through tool input
server.tool("search_users", {
  query: z.string(),
}, async ({ query }) => {
  // If query is "'; DROP TABLE users; --" this is catastrophic
  const result = await db.query(`SELECT * FROM users WHERE name LIKE '%${query}%'`);
  return { content: [{ type: "text", text: JSON.stringify(result.rows) }] };
});

// SAFE: Parameterized queries
server.tool("search_users", {
  query: z.string().max(100),  // Length limit
}, async ({ query }) => {
  const result = await db.query(
    "SELECT * FROM users WHERE name ILIKE $1 LIMIT 50",
    [`%${query}%`]
  );
  return { content: [{ type: "text", text: JSON.stringify(result.rows) }] };
});

Sanitization rules for MCP tools:

  • Use parameterized queries for all database operations
  • Set maximum lengths on all string inputs via Zod schema
  • Validate enums strictly — use z.enum() not z.string()
  • Sanitize file paths to prevent directory traversal (../../etc/passwd)
  • Never pass raw input to shell commands — use exec with args array, not string
// VULNERABLE: Command injection
const output = execSync(`ls -la ${userPath}`);  // userPath could be "; rm -rf /"

// SAFE: Arguments as array, no shell interpretation
const output = execFileSync("ls", ["-la", userPath]);

Rate Limiting

AI agents can call tools rapidly in loops. Without rate limiting, a misbehaving agent could overwhelm your server or burn through API quotas.

// Simple in-memory rate limiter
class RateLimiter {
  private windows = new Map<string, { count: number; resetAt: number }>();

  constructor(
    private maxRequests: number,
    private windowMs: number,
  ) {}

  check(key: string): { allowed: boolean; retryAfterMs?: number } {
    const now = Date.now();
    const window = this.windows.get(key);

    if (!window || now > window.resetAt) {
      this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
      return { allowed: true };
    }

    if (window.count >= this.maxRequests) {
      return {
        allowed: false,
        retryAfterMs: window.resetAt - now,
      };
    }

    window.count++;
    return { allowed: true };
  }
}

// Usage: 100 requests per minute per user
const limiter = new RateLimiter(100, 60_000);

// In tool handler:
const check = limiter.check(userId);
if (!check.allowed) {
  return {
    content: [{
      type: "text",
      text: JSON.stringify({
        error: {
          code: "RATE_LIMITED",
          retryable: true,
          retry_after_seconds: Math.ceil(check.retryAfterMs! / 1000),
        },
      }, null, 2),
    }],
    isError: true,
  };
}

Transport Security

Stdio transport is inherently local — the security boundary is the machine. But HTTP-based transports (SSE, Streamable HTTP) need TLS and additional protections.

// Security checklist for HTTP MCP servers:

// 1. TLS (HTTPS) — always, no exceptions
// 2. CORS — restrict to known origins
app.use(cors({
  origin: ["https://claude.ai", "https://your-app.com"],
  credentials: true,
}));

// 3. Helmet-style headers
app.use((req, res, next) => {
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
  next();
});

// 4. Request size limits — prevent payload bombs
app.use(express.json({ limit: "1mb" }));

// 5. Audit logging — track who did what
server.tool("delete_record", { id: z.string() }, async ({ id }, context) => {
  auditLog.write({
    action: "delete_record",
    record_id: id,
    user: context.auth?.userId,
    timestamp: new Date().toISOString(),
    ip: context.request?.ip,
  });
  // ... actual deletion
});

Exercise: Secure a Server

Take the weather server from Phase 2 and add:

  1. OAuth 2.1 discovery endpoint and token verification middleware
  2. Input sanitization on the city name parameter (max length, allowed characters)
  3. Rate limiting: 60 requests per minute per authenticated user
  4. Audit logging for every tool call
  5. Proper error responses for auth failures, rate limits, and invalid input

Check Your Understanding

  1. Why does MCP use OAuth 2.1 with PKCE instead of simple API keys?
  2. What is envelope encryption, and why is it better than encrypting credentials directly?
  3. Name three types of injection attacks that MCP tools are vulnerable to.
  4. Why is rate limiting especially important for AI-facing servers?
  5. What security differences exist between stdio and HTTP MCP transports?

Key Takeaway

Security in MCP is not a feature you add later — it is a design constraint from day one. Use OAuth 2.1 + PKCE for authentication. Encrypt credentials with envelope encryption. Sanitize every input as if it came from an untrusted user (because it did, through the AI). Rate limit to protect against runaway agents. Log everything for audit trails. A production MCP server without these protections is a liability.