All posts
MCP education··7 min read

MCP server security: a production checklist

Most MCP security incidents trace back to four configuration mistakes: a long-lived shared API key, missing tool-level authorization, no per-user rate limit or cost cap, and tool output trusted as instructions. The fix is a small set of defaults — short-lived per-user JWTs, scope checks inside every tool, a gateway in front, confirmation steps on destructive writes, and append-only audit logs keyed on request and user IDs.

⌘
Command+K team
Platform engineering

Start with a threat model, not a checklist

MCP servers are a new attack surface with old-fashioned risks: stolen credentials, over-privileged tokens, missing audit, and confused-deputy bugs where one user's request runs with another user's authority. The threats aren't novel; the configuration mistakes are. Walk through who calls your server, with what identity, and what damage a compromised token can do before they expire.

Identity: one user, one token, short-lived

  1. Mint a short-lived JWT (5–15 minutes) on your backend for each end-user session.
  2. The JWT carries sub (user ID), workspace ID, and the scopes the user actually has.
  3. The MCP server validates the JWT on every request, never trusts sub from the body.
  4. Rotate transparently — the widget fetches a new token before expiry, no user-visible re-auth.

Anti-pattern

A single long-lived API key shared across users means every tool call runs as the same identity. You lose per-user audit, you can't revoke one user, and a leaked widget token compromises the entire workspace.

Scopes: same rules as your REST API

Every tool checks the caller's permissions at invocation time, exactly as your REST API does. The LLM is an untrusted client. Do not assume the model "knows" what the user is allowed to do — schema descriptions are hints, not authorization.

Put a gateway in front

  • Per-user rate limits — token bucket keyed on sub, not IP.
  • Hard per-user cost cap — once hit, return a friendly error, not a 500.
  • Request-ID propagation so a single user complaint is traceable across model, gateway, and tool.
  • Quota state in a fast store (Redis or pgmq) — never trust client-side counters.

Confirmation for destructive writes

Reads and reversible writes can run uninterrupted. Destructive or irreversible writes — delete, send, publish, charge — should return a confirmation-required response that the host renders as an explicit user click. This is a UX pattern and a defense-in-depth measure against prompt injection.

Prompt injection in tool output

Any tool that returns text the LLM reads back is an injection vector. Treat every byte from a tool as untrusted user data, never as instructions:

  • Quote-fence or escape model-bound output server-side.
  • Never let tool output unlock tools the original user didn't have.
  • Log raw outputs so a future incident is reconstructible.

Audit logging that survives discovery

  • Log: request ID, user ID, workspace ID, tool name, redacted inputs, outcome, duration.
  • Append-only sink — Postgres + retention policy or an object store with WORM lock.
  • Searchable by user ID for support and by request ID for debugging.
  • Redact PII at write time, not at read time.

For what a security team actually asks to see when they review this, see MCP audit logs that actually matter at security review.

Secrets and OAuth tokens

  • Store provider tokens in an encrypted_secrets table with row-level encryption.
  • RLS so a tenant can only read their own row; service role only for the gateway.
  • Rotate refresh tokens on use; never log raw tokens.

Production checklist

  • Short-lived per-user JWTs, no shared keys.
  • Tool-level authorization, not just transport-level.
  • Gateway with per-user rate limit + cost cap.
  • Confirmation step on destructive writes.
  • Tool output treated as untrusted.
  • Append-only audit log keyed on request and user IDs.
  • Encrypted secret storage with RLS.
  • SOC 2 evidence: deletion runbook, breach playbook, on-call rotation.

Command+K ships all of this out of the box — see Build MCP.

FAQ

What's the single biggest MCP security mistake?
Sharing one long-lived API key across all end-users. Every tool call then runs as the same identity, you lose per-user audit, and a leaked widget token compromises the whole workspace. Use short-lived JWTs minted per end-user instead.
How long should an MCP session token live?
5–15 minutes for end-user JWTs, rotated transparently by your backend. Long enough to cover a typical assistant turn, short enough that a leaked token is useless before an attacker can exploit it.
Do I need a gateway in front of my MCP server?
For anything multi-tenant, yes. The gateway enforces per-user rate limits, hard cost caps, audit logging, and request-ID propagation. Without it, a runaway agent loop in one tenant can degrade every other tenant.
Should write tools require human confirmation?
Destructive or irreversible writes (delete, send, charge, publish) should require an explicit confirmation step rendered in the host. Reads and reversible writes can run without confirmation if rate-limited and audited.
How do I handle prompt injection in tool outputs?
Treat all tool output as untrusted data, never as instructions. Strip or quote-fence content before passing it back to the model, never let tool output trigger tool calls that escalate scope, and log the raw output for forensic review.

Keep reading

Go deeper