ASK KNOX
beta
LESSON 402

Auth & Secrets in MCP Servers — Bearer Tokens, Scoped Access, Never Leaking

The model reads tool output. Any secret in that output is a leaked secret. Bearer tokens, scoped access per caller, and five leak patterns you must never implement.

9 min read·Building Production MCP Servers

The Unique Security Challenge of MCP Servers

MCP servers have a security property that makes them different from typical APIs: the consumer is an AI model. When your REST API returns an error containing a stack trace, a human developer reads it and understands not to share it. When your MCP tool returns an error containing process environment variables, the model reads it and includes those variables in its active context window — where they may appear in responses, get logged to context files, or be shared in session exports.

The model does not understand that some content is sensitive. It reads everything it receives and treats it as information to work with. This means the security standard for MCP tool output is higher than for typical APIs: no secret can appear in any tool response, ever, under any circumstance.

The Scoped Access Model

Semantic Memory Layer uses two tokens with different scope sets. Claude Code sessions get a read-only token that allows memory_query, memory_recall, memory_status, memory_context, memory_namespaces, and memory_events — the 6 read-safe tools from the 12-tool surface (the Claude Code token deliberately grants a subset of read-safe tools, excluding write and operations tools even though those are also non-destructive). Agent Gateway gets a full-access token that allows all tools. Unauthenticated requests get access to exactly one endpoint: /health, and only for the liveness check.

This is not just a security measure. It is a correctness measure. Claude Code sessions should not be creating arbitrary memories — that is Agent Gateway's job. Giving Claude Code read-only access ensures the memory store's integrity: only the authorized writer can add to it.

The implementation is straightforward: the HTTP middleware extracts the Authorization header, validates the token against the secret, checks its scope claims, and either passes the request to the tool router or returns 401/403.

The Correct Secret Flow

The secret flow diagram shows the correct path for every secret in your MCP server. A secrets manager stores it — this setup uses Doppler, a hosted product that centralizes secrets and injects them at runtime, but the pattern is identical with 1Password CLI, HashiCorp Vault, or AWS Secrets Manager. The Doppler CLI injects the secret at compose time. The server reads it from process.env at startup. The validation function compares incoming tokens using crypto.timingSafeEqual to prevent timing attacks. The logger records the caller's identity (a scoped name, not the token). Tool responses contain query results, never configuration or secrets.

The four wrong flows — token as tool argument, token in committed .env, env dump in error handler, token in response — are not theoretical mistakes. They are the patterns that appear under deadline pressure when "I'll secure it properly later" thinking takes over.

Timing-Safe Token Comparison

A subtle security requirement: token comparison must be timing-safe. Naive string comparison in JavaScript short-circuits on the first mismatch: === returns false faster for tokens that share no prefix than for tokens that share a long prefix.

An attacker who can make thousands of requests can use timing differences to determine what the correct token starts with. crypto.timingSafeEqual always takes the same time regardless of where the mismatch occurs.

import crypto from 'crypto'

function validateBearer(incoming: string, expected: string): boolean {
  const a = Buffer.from(incoming)
  const b = Buffer.from(expected)
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}

This is a two-line fix that eliminates the timing attack vector. It belongs in every MCP server that validates bearer tokens.

You now have validateBearer. Build the guard that uses it. The two tokens from the scoped-access model — one read-only, one read-write — get enforced here, at the routing boundary, so a read-only caller physically cannot reach a write tool.

Log Safely

The logger is the most common vector for accidental secret exposure. Logging the full request object is the standard debugging pattern in development. In production, that pattern logs the Authorization header — and now your secret is in your log files.

The correct pattern: log a computed representation, not the raw header. For Semantic Memory Layer:

// WRONG — logs the bearer token
logger.info({ headers: req.headers, tool: toolName })

// CORRECT — logs caller identity derived from the validated token
logger.info({
  caller_id: tokenToCallerId(req.headers.authorization),
  tool_name: toolName,
  duration_ms: elapsed,
  namespace: args.namespace,
})

The caller_id is a stable string derived from the token — claude-code-read or agent-gateway-write — not the token itself. It provides auditability (who called what) without leaking the secret.

What's Next

The next lesson covers the read vs write safety model — the three tiers of tool safety, destructive-tool gating, and idempotency patterns that make write tools safe to retry in the presence of network failures or agent loops.