How to Secure MCP Servers: Auth + AuthZ [2026 Tutorial]

A gateway-grade blueprint for MCP servers: authenticated discovery, PKCE/OAuth done right, tool-scoped permissions, audit logs, rate limits, and secret handling—with runnable Node/Express code.

Part of theDev Tools & AI Workflow series
Facebook login screen with email and password fields
Listen to this article
--:--

If you’re deploying MCP server authentication authorization in production, stop treating your MCP server like “a helpful tool adapter.” It’s not. The moment it’s on HTTP, it’s an internal API gateway that an AI client can drive.

That should make you a little uncomfortable. Because now you’ve got a remotely callable control plane pointed at GitHub, Stripe, internal admin APIs, databases, file stores. And in 2026, teams are spinning these up fast. Often generated from OpenAPI. Often with OAuth duct-taped on the night before launch. Often with tool schemas that are basically a dare.

This post is a concrete, gateway-grade blueprint. Auth middleware. Tool-scoped permissions. Per-request policy checks. Audit logs that are actually useful. Rate limits that survive agent retry loops. Secret handling that doesn’t leak credentials into schemas or logs.

Freshness note: MCP protocol revisions are date-named. The current docs list Version 2026-07-28 (latest) in the official MCP documentation, and newer revisions lean into stateless, per-request metadata rather than session assumptions. Great. That also means your authZ needs to happen per request, every time.

(At two natural section breaks, I’d drop images like: “gateway-style MCP request flow diagram” and “tool permission matrix + audit log fields”.)

What is Model Context Protocol (MCP)?

Model Context Protocol (MCP) is an open-source standard for connecting AI applications to external systems so a client can discover tools (tools/list) and call them (tools/call) in a consistent way. The official docs pitch it as “a USB‑C port for AI applications” (MCP documentation).

Close-up of server cooling fans in a vibrant data center

The metaphor is cute. Operationally, an MCP server is a programmable proxy sitting between an AI client and real systems.

So the threat model is not “a user clicked a button in my UI.” It’s:

  • Agents as untrusted clients. They retry. They hallucinate arguments. They chain tools in ways you didn’t plan.
  • Prompt injection becomes API call injection. If the model can be nudged into calling a tool, your server is the last line of defense.
  • Confused deputy is the default failure mode. Your MCP server holds broad downstream credentials by design.

This is why I keep saying “API gateway.” Your MCP server needs gateway controls: authN, authZ, tenancy, input validation, logging, and traffic shaping.

One uncomfortable data point: MCPulse analyzed tool schemas from 4,951 public MCP servers and found 87,146 tools and 270,487 parameters. In that dataset, “one parameter in five has no description at all.” That’s not just bad UX. That’s mis-call fuel.

MCP server authentication & authorization checklist (gateway-grade)

This is the minimum bar I’d accept for a remote MCP server.

a close up of a server in a server room
  • Require auth for `tools/list` and `tools/call` (and anything else you expose). Default deny.
  • Verify tokens locally: iss, aud, signature via JWKS, expiry, clock skew.
  • Enforce PKCE (S256) for Authorization Code flows. No exceptions for web/desktop clients.
  • Never do token passthrough. Never accept foreign-audience tokens.
  • Derive tenant from verified claims, not request params.
  • Map tools to explicit permissions/scopes. Check before execution.
  • Validate tool arguments against JSON Schema, plus parameter allowlists.
  • Emit structured audit logs for every tool call. Hash args. Redact secrets.
  • Add per-principal + per-tool rate limits with burst and sustained windows.
  • Make error messages safe. Don’t leak tool names, IDs, or downstream details.
  • Store downstream secrets in a vault/KMS. Rotate. Prefer short-lived tokens.
  • Add a security test plan: curl checks, unit tests, CI gates for regressions.

Tool calls that work without authentication

The most common failure is also the dumbest one: leaving discovery open because it “doesn’t change anything.”

Close-up of server cooling fans in a vibrant data center

It changes everything.

Yimmie Honrodt puts it bluntly: if tools/list returns a tool list without an Authorization header, “stop reading and fix this first” (Yimmie Honrodt).

The practical reason is simple. tools/list is your attack surface map.

  • If the tool list includes run_sql, deploy_service, reset_password, you just handed an attacker a menu.
  • Even if tools are protected, leaking names and schemas makes it easier to craft payloads and social-engineer operators.

Gateway stance: if it’s callable, it’s authenticated. Discovery included.

There’s also a “reliability becomes security” angle here. MCPulse found that 1 in 6 tools had descriptions with “no word that distinguishes it from a sibling tool on the same server”, and on servers with >60 tools it’s “nearly 1 in 3.” Ambiguous tool lists are how agents call the wrong thing. In security terms, that’s how you end up executing actions you didn’t mean to authorize.

How to implement MCP server authentication (HTTP vs stdio)

There are two different realities.

Remote (HTTP) MCP: authenticate like you mean it

For HTTP transports, use bearer tokens (JWT access tokens in most shops) and verify them on every request.

You need to validate:

  • Signature via JWKS
  • iss equals your expected issuer
  • aud contains your MCP server audience
  • exp and nbf
  • (optionally) azp / client_id if you care which client app is calling

Do not accept “any token from our IdP.” That’s how you end up accepting foreign-audience tokens.

Below is a runnable Node/Express reference implementation that enforces auth on both tools/list and tools/call, with default-deny.

js
// package.json
// {
//   "type": "module",
//   "dependencies": {
//     "express": "^4.19.2",
//     "jose": "^5.6.3",
//     "pino": "^9.3.2",
//     "zod": "^3.23.8"
//   }
// }

import express from "express";
import crypto from "crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
import pino from "pino";
import { z } from "zod";

const app = express();
app.use(express.json({ limit: "256kb" }));

const log = pino({ level: process.env.LOG_LEVEL ?? "info" });

// === Auth config ===
const ISSUER = process.env.OIDC_ISSUER; // e.g. https://auth.example.com/
const AUDIENCE = process.env.MCP_AUDIENCE; // e.g. https://mcp.example.com

if (!ISSUER || !AUDIENCE) {
  throw new Error("Set OIDC_ISSUER and MCP_AUDIENCE");
}

const JWKS = createRemoteJWKSet(new URL(`${ISSUER}.well-known/jwks.json`));

async function authenticate(req, res, next) {
  const header = req.get("authorization") ?? "";
  const match = header.match(/^Bearer\s+(.+)$/i);
  if (!match) return res.status(401).json({ error: "unauthorized" });

  try {
    const token = match[1];
    const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: "5s",
    });

    req.auth = {
      sub: payload.sub,
      tenant: payload.tid ?? payload.tenant_id ?? null,
      scopes: (payload.scope ?? "").split(" ").filter(Boolean),
      clientId: payload.azp ?? payload.client_id ?? null,
      kid: protectedHeader.kid ?? null,
    };

    if (!req.auth.sub) return res.status(401).json({ error: "unauthorized" });
    return next();
  } catch {
    return res.status(401).json({ error: "unauthorized" });
  }
}

// Apply auth to MCP endpoints
app.post("/mcp/tools/list", authenticate);
app.post("/mcp/tools/call", authenticate);

Local (stdio) MCP: you’re not doing user auth

If your MCP server is stdio-only, your security story is different.

  • Stdio security is about host trust and client integrity.
  • If the machine is compromised, the attacker can talk to your server anyway.

What you can still do:

  • Make “local mode” explicit (MCP_TRANSPORT=stdio) and refuse to start in HTTP mode without auth config.
  • Avoid reading secrets from environment variables that the client can influence.
  • Reduce blast radius by using least-privilege downstream creds even locally.

OAuth without PKCE

If you do OAuth Authorization Code without PKCE, you’re just shipping an authorization code interception bug with nicer branding.

The canonical reference is RFC 7636: “Proof Key for Code Exchange by OAuth Public Clients” by Nat Sakimura, John Bradley, and Nikhil Agarwal (Nat Sakimura).

In Honrodt’s checklist, the test is practical: check the authorization server metadata and ensure code_challenge_methods_supported contains `S256`.

For MCP clients that run on developer machines (desktop apps, IDE plugins), assume hostile local environments. Auth codes get intercepted. Tokens get copied. People install “helpful” extensions. You don’t win by pretending the box is clean.

Gateway stance:

  • Require PKCE for every code flow.
  • Require S256.
  • Don’t allow plain unless you have a very specific constrained-client reason.

If you’re building your own authorization server, implement the S256 verifier correctly. If you’re using an IdP (Auth0, Okta, Entra, Keycloak), turn on “PKCE required” and verify it through discovery.

Token passthrough and the confused deputy problem

This is where MCP servers become accidental backdoors.

The confused deputy is when your MCP server (the deputy) holds higher privileges than the caller, and can be tricked into using those privileges for something it should reject.

Honrodt describes two shapes that matter for MCP:

  1. Token passthrough: client sends a token, server forwards it downstream unchanged.
  2. Foreign-audience token acceptance: server accepts tokens minted for a different service.

The fix is not subtle: servers must only accept tokens valid for their own resources and must not accept or transit other tokens. If the server calls a downstream API, it should act as an OAuth client and use a separate downstream token (Yimmie Honrodt).

Here’s what token passthrough looks like in real code: your tool implementation grabs req.headers.authorization and forwards it to GitHub/Jira/your internal API.

Don’t do it.

Instead:

  • Authenticate the caller to your MCP server.
  • Authorize the tool call locally.
  • Use server-held downstream credentials (preferably short-lived) scoped to the exact operation.

Below is a minimal “token exchange” stub. In real deployments this is often a client-credentials token to a downstream API, or an on-behalf-of flow if your identity platform supports it.

js
async function getDownstreamToken({ toolName, tenant }) {
  // Keep this out of request headers. Do not forward caller tokens.
  // In production: fetch from a token broker, STS, or OAuth client_credentials.

  if (!tenant) throw new Error("tenant_missing");

  // Example: different downstream audiences per tool
  const audience = toolName.startsWith("github.")
    ? "https://api.github.com"
    : "https://internal-api.example.com";

  // Placeholder. Replace with a real token fetch.
  return { accessToken: process.env.DOWNSTREAM_ACCESS_TOKEN, audience };
}

Authorization: tool-scoped permissions, tenant binding, and parameter constraints

AuthN gets you an identity. AuthZ decides whether that identity can do the thing.

This is where a lot of MCP servers get dangerously lazy. They do “is logged in” and then happily run whatever tool the client asks for.

I prefer a boring model:

  • A static map: tool → required scopes
  • A tenant claim on the token (tid, tenant_id, etc.)
  • Optional parameter allowlists (repo IDs, org IDs, environment names)
  • Default deny when a tool isn’t in the map

Also, answer the PAA question directly: authentication is “who are you?” and authorization is “are you allowed to do this?” Confusing them is how you end up with “logged in users can deploy to prod.”

Here’s the policy engine and enforcement.

js
// Tool permission model
const TOOL_POLICY = {
  "tickets.create": { scopesAny: ["tickets:write"], tenantRequired: true },
  "tickets.read": { scopesAny: ["tickets:read", "tickets:write"], tenantRequired: true },
  "github.open_pr": { scopesAny: ["github:write"], tenantRequired: true },
  "admin.reset_password": { scopesAny: ["admin"], tenantRequired: true },
};

function authorizeToolCall({ auth, toolName, args }) {
  const policy = TOOL_POLICY[toolName];
  if (!policy) {
    return { ok: false, reason: "tool_not_allowlisted" };
  }

  if (policy.tenantRequired && !auth.tenant) {
    return { ok: false, reason: "tenant_missing" };
  }

  const scopes = new Set(auth.scopes);
  const hasScope = policy.scopesAny?.some((s) => scopes.has(s)) ?? true;
  if (!hasScope) {
    return { ok: false, reason: "insufficient_scope" };
  }

  // Example parameter constraint: only allow repos from an allowlist
  if (toolName === "github.open_pr") {
    const allowedOrgs = new Set(["acme-inc", "acme-labs"]);
    if (args?.org && !allowedOrgs.has(args.org)) {
      return { ok: false, reason: "org_not_allowed" };
    }
  }

  return { ok: true };
}

Now wire that into tools/call, with strict argument validation.

js
const ToolCallSchema = z.object({
  name: z.string().min(1),
  arguments: z.record(z.any()).default({}),
});

app.post("/mcp/tools/call", authenticate, async (req, res) => {
  const parsed = ToolCallSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: "invalid_request" });
  }

  const { name: toolName, arguments: args } = parsed.data;

  // IMPORTANT: tenant comes from verified claims, not request params.
  const decision = authorizeToolCall({ auth: req.auth, toolName, args });
  if (!decision.ok) {
    auditLog({
      event: "tool_call",
      allowed: false,
      auth: req.auth,
      toolName,
      args,
      denyReason: decision.reason,
      req,
    });
    return res.status(403).json({ error: "forbidden" });
  }

  // Rate limit before executing anything expensive
  const rl = rateLimit({ auth: req.auth, toolName });
  if (!rl.ok) {
    auditLog({
      event: "tool_call",
      allowed: false,
      auth: req.auth,
      toolName,
      args,
      denyReason: "rate_limited",
      req,
    });
    res.set("Retry-After", String(rl.retryAfterSeconds));
    return res.status(429).json({ error: "rate_limited" });
  }

  const downstream = await getDownstreamToken({ toolName, tenant: req.auth.tenant });

  // Execute the tool (placeholder)
  const result = { ok: true, toolName, downstreamAudience: downstream.audience };

  auditLog({
    event: "tool_call",
    allowed: true,
    auth: req.auth,
    toolName,
    args,
    req,
  });

  return res.json({ result });
});

This is also where multi-tenancy mistakes show up. Honrodt calls out “tenant derived from a request parameter” as a common failure. If your request body contains tenantId and you trust it, you’ve already lost.

Logging & audit trails: what to log, what to redact

If your MCP server becomes the control plane for agent actions, your audit log is your “what happened?” system. Without it, you’re debugging with vibes.

Make it boring and consistent. For every tool call, log:

  • ts timestamp (ISO)
  • request_id (generated if missing)
  • principal_sub
  • tenant
  • client_id / azp
  • tool_name
  • allowed + deny_reason
  • args_hash (not raw args)
  • arg_keys (surprisingly useful)
  • downstream_request_id (if you have one)

And redact:

  • Authorization headers
  • API keys
  • Anything that looks like a bearer token
  • Tool args that may contain secrets (passwords, tokens, connection strings)

While building and maintaining 25+ browser developer tools on this site (/tools), the only way I’ve kept integrations manageable is standardizing log fields and redaction rules up front. Otherwise you can’t correlate incidents, or even basic support tickets, across tools.

Here’s a simple audit logger with arg hashing and redaction.

js
function stableJson(obj) {
  return JSON.stringify(obj, Object.keys(obj).sort());
}

function sha256(input) {
  return crypto.createHash("sha256").update(input).digest("hex");
}

function redactArgs(args) {
  const SENSITIVE_KEYS = ["token", "access_token", "api_key", "password", "secret", "authorization"];
  const out = {};
  for (const [k, v] of Object.entries(args ?? {})) {
    if (SENSITIVE_KEYS.some((s) => k.toLowerCase().includes(s))) {
      out[k] = "[REDACTED]";
    } else {
      out[k] = v;
    }
  }
  return out;
}

function auditLog({ event, allowed, auth, toolName, args, denyReason, req }) {
  const requestId = req.get("x-request-id") ?? crypto.randomUUID();
  const redacted = redactArgs(args);

  log.info({
    event,
    ts: new Date().toISOString(),
    request_id: requestId,
    principal_sub: auth.sub,
    tenant: auth.tenant,
    client_id: auth.clientId,
    tool_name: toolName,
    allowed,
    deny_reason: denyReason ?? null,
    args_hash: sha256(stableJson(redacted)),
    arg_keys: Object.keys(args ?? {}),
    ip: req.ip,
  });
}

If you want tamper-evidence, ship logs append-only to something like Cloud Logging + bucket retention lock, or a SIEM with write-once semantics. The key is: don’t store audit logs in the same database the tools can mutate.

If you want deeper guidance on redaction and retention in AI systems, I’ve got a full playbook at LLM security and a more implementation-heavy piece on AI security.

No rate limiting (and why agents make this worse)

Rate limiting for humans is straightforward. Rate limiting for agents is annoying, because agents behave like the worst client you’ve ever had.

They retry. They loop. They fan out. They do it at machine speed. And when a model gets “stuck,” it’ll happily hammer the same tool call until your downstream falls over.

Honrodt calls out “no rate limiting” as one of the seven common failures, and it’s the one that turns “minor bug” into “your downstream got melted.”

You want at least two dimensions:

  • Per principal (user/service identity)
  • Per tool (because search and reset_password should not share a bucket)

And you want burst + sustained behavior.

Below is an in-memory token bucket. In production, use Redis or an API gateway (Envoy, Kong, NGINX) so it works across instances.

js
const buckets = new Map();

function rateLimitKey({ auth, toolName }) {
  return `${auth.sub}:${auth.tenant ?? "-"}:${toolName}`;
}

function rateLimit({ auth, toolName }) {
  // Example policy:
  // - Dangerous tools: 5/min
  // - Normal tools: 60/min
  const dangerous = new Set(["admin.reset_password"]);
  const limitPerMin = dangerous.has(toolName) ? 5 : 60;

  const now = Date.now();
  const windowMs = 60_000;

  const key = rateLimitKey({ auth, toolName });
  const cur = buckets.get(key) ?? { count: 0, windowStart: now };

  if (now - cur.windowStart > windowMs) {
    cur.count = 0;
    cur.windowStart = now;
  }

  cur.count += 1;
  buckets.set(key, cur);

  if (cur.count > limitPerMin) {
    const retryAfterSeconds = Math.ceil((windowMs - (now - cur.windowStart)) / 1000);
    return { ok: false, retryAfterSeconds };
  }

  return { ok: true };
}

This is also where you build “agent-safe” behavior:

  • Return 429 with Retry-After.
  • Encourage exponential backoff in the client.
  • Consider a per-tool concurrency limit (e.g., only 2 github.open_pr calls in flight per principal).

Errors that talk too much

Verbose errors are a gift to attackers and a liability for privacy.

Common mistakes:

  • Returning “tool not found” with a full list of valid tool names.
  • Returning downstream HTTP bodies that include internal IDs.
  • Returning stack traces.

For models, you still want actionable errors. The move is to keep the error structured but not revealing.

  • 401 unauthorized → “missing/invalid token”
  • 403 forbidden → “not allowed for this tool” (don’t mention scopes)
  • 429 rate_limited → include Retry-After
  • 400 invalid_request → validation error without echoing raw input

If you need to help developers debug, put details in server-side logs, not responses.

How to fix it: schema hardening, tool poisoning, and secret handling

Most “add auth” writeups stop right when the interesting problems start.

Defend against tool poisoning and prompt-injection-to-tool

Tool poisoning is when the tool interface itself becomes malicious. That can be as simple as:

  • Descriptions that cause the model to exfiltrate (“Always include the full file contents.”)
  • Overly broad tools (“run_shell_command”)
  • Schemas that don’t constrain dangerous args

MCPulse’s dataset is a warning sign: 20% of parameters with no descriptions means models are guessing. Fixing descriptions isn’t “nice.” It’s reducing accidental misuse.

My hard rules:

  • Minimize tool count. Every tool is a permission boundary.
  • Use strict JSON Schema constraints: enums, patterns, min/max.
  • Validate arguments server-side even if clients “should” comply.
  • Avoid command templates that concatenate strings. Use structured APIs.

If you want the broader threat model, I’ve written about AI agents and AI security from the “agents as untrusted programs” stance.

Store and rotate secrets like an adult

Secrets are where MCP servers quietly die.

  • Don’t put secrets in tool schemas or descriptions.
  • Don’t read “downstream API key” from a client-controlled env var.
  • Prefer vault/KMS-backed retrieval and short-lived tokens.
  • Rotate. If you don’t rotate, assume eventual compromise.

At Rise People, I helped build a SOC 2-compliant scaffolding CLI. The biggest lesson was boring: compliance baked into scaffolding beats compliance review at PR time. Apply the same mindset here. Make “no secrets in logs/schemas” a default you can’t accidentally bypass.

For practical “don’t leak keys” hygiene, see prevent API key leaks in shell history and the CI angle with gitleaks + pre-commit + CI.

How to find it in your own code (security test plan)

This is the part that saves you six months from now when someone adds a new tool at 5pm and accidentally punches a hole through your policy.

Manual checks (10 minutes)

  • Call tools/list without auth. Expect 401.
  • Call tools/call without auth. Expect 401.
  • Call a tool you shouldn’t have scope for. Expect 403.
  • Try passing tenantId in args and see if it changes behavior. It shouldn’t.
  • Trigger rate limit with a loop. Expect 429 with Retry-After.

Unit tests (these catch regressions)

Write tests that assert:

  • Every route under /mcp/* requires auth.
  • Every tool in your registry has an entry in TOOL_POLICY.
  • Unknown tools are denied.

Example (Jest-style pseudocode):

js
// Pseudocode: assert default deny
expect(authorizeToolCall({ auth: fakeAuth(["tickets:read"]), toolName: "unknown", args: {} }).ok)
  .toBe(false);

CI enforcement

Add a “security linter” step that fails builds when:

  • A tool exists without policy.
  • A tool schema lacks parameter descriptions.
  • A tool allows free-form string input where you expect an enum.

This is the same pattern I like for CI/CD: don’t rely on PR comments to enforce invariants.

If you maintain a server

If you maintain an MCP server that’s already live, you don’t need a rewrite. You need a sequence:

  1. Put auth on tools/list and tools/call today.
  2. Add audience/issuer checks and reject foreign tokens.
  3. Add tool policies and default deny.
  4. Add audit logging with arg hashing and redaction.
  5. Add rate limits.
  6. Move secrets out of env and into a vault/token broker.
  7. Start deleting tools. Fewer tools beats clever prompts.

One more practical note: schema quality is not fluff. MCPulse’s study looked at 4,951 servers and found interface ambiguity at scale. If you ship 60+ tools, they found “nearly 1 in 3” descriptions fail to distinguish siblings. That’s the point where humans and models both stop understanding your surface area.

Here’s the official “what is MCP” overview if you need to align stakeholders on terminology: MCP documentation.

And if you’re building agents as a product, don’t ignore the bigger production story. You’ll eventually need production AI practices: observability, evals, and failure testing. Start with AI agents, then build the hardening loop.

Here’s the IBM overview video I send non-specialists so they stop thinking MCP is “just a plugin format”:

The next year: MCP servers become the new internal admin panel

My prediction: within the next year, “MCP server” will be treated by security teams the way we treat internal admin panels today. Not because it’s trendy. Because it’s the same thing with a different client.

If you’re building a secure MCP server, stop asking “does it work?” Start asking: “what’s the blast radius when it works exactly as designed for the wrong caller?”

Photo by Zulfugar Karimov on Unsplash.

Continue reading

MCP: The USB-C of AI — How Model Context Protocol Is Connecting Everything

MCP: The USB-C of AI — How Model Context Protocol Is Connecting Everything

From a quiet Anthropic open-source release to 100 million downloads per month, MCP is becoming the universal standard for connecting AI agents to tools and data.

markdown documentation code editor laptop screen — illustration for article on Agent Readable Documentation Toolchain [2026]:

Agent Readable Documentation Toolchain [2026]: My Stack

A docs-as-code pipeline that stays great for humans, useful for your trusted AI agents, and aggressively unhelpful to random scrapers. Practical, not theoretical.

a laptop computer sitting on top of a wooden desk

How to Use an SSH Config Manager on macOS [2026] (Secure Jump Hosts)

A hardened ~/.ssh/config template for ProxyJump, multiplexing, per-host keys, and safer tunnels. Plus when macOS SSH GUI managers help and when they hurt.

padlock on laptop with light trails

LLM Supply Chain Security Checklist: Lock Down Agents [2026]

A CI-ready checklist to treat models, MCP servers, tool plugins, and prompt packs as real dependencies. Pin, sign, attest, sandbox, and monitor before your agent ships malware.

Cite this article
Kunal Ganglani (2026, September 9). How to Secure MCP Servers: Auth + AuthZ [2026 Tutorial]. Kunal Ganglani. Retrieved September 9, 2026, from https://www.kunalganglani.com/blog/mcp-server-authentication-authorization

Frequently Asked Questions

What is the difference between authentication and authorization?

Authentication is proving who the caller is (for example, validating a signed JWT and extracting the user identity). Authorization is deciding what that authenticated caller is allowed to do (for example, whether they can call `admin.reset_password`). In MCP servers, teams often do the first and forget the second, which is how “logged in” becomes “can run every tool.”

What is PKCE and why is it required?

PKCE (Proof Key for Code Exchange) is an OAuth protection that prevents intercepted authorization codes from being redeemed by an attacker. The client creates a one-time verifier, sends a hashed challenge during authorization, then proves possession of the verifier when exchanging the code for tokens. For MCP clients that run on developer machines (desktop apps, IDEs), you should assume code interception is possible, so PKCE with S256 is the safe default.

What is the confused deputy problem in security?

A confused deputy is a system that holds higher privileges than the caller and can be tricked into using those privileges on the caller’s behalf. MCP servers are deputies because they often have broad downstream credentials. The classic MCP failure is token passthrough or accepting tokens meant for a different audience, which lets an attacker drive the MCP server’s privileges without being properly authorized.