Authentication & Secrets
Lesson 09 · MCP Servers · ~10 min read
The two auth concerns
Real MCP servers live in two authentication conversations at once, and confusing them is the
source of most production incidents. The first concern is authenticating the user to
your server — when a request to fetch or write arrives,
whose notes are you supposed to load? Without an answer, your “personal” knowledge
base is either a single shared bucket (fine for one developer, a leak waiting to happen for
anyone else) or nothing at all. The second concern is authenticating your server to a
third-party API: when PKB decides to call Anthropic's sampling endpoint or a
translation provider, the third party needs proof that the request is from you and not from
someone who scraped your code off GitHub.
These two concerns use different mechanisms. The user is authenticated to your server by an
OAuth bearer token (over a remote transport) or a stub principal (in local development) that
the server validates and converts into an identity. Your server is authenticated to a
third-party API by an API key or service-account credential that your server holds
and the third party verifies. The key never crosses the user-facing boundary; the user's token
never reaches the third party. PKB will demonstrate both in this lesson: a stub user-auth via
the PKB_USER environment variable, and a hypothetical
PKB_THIRD_PARTY_KEY as the canonical example of a third-party credential loaded
from .env.
OAuth 2.1 + PKCE for remote MCP
The MCP specification aligns its remote auth model with OAuth 2.1, which mandates PKCE (Proof Key for Code Exchange) for every public client. The flow has seven steps, and once you've seen it once it's the same dance every remote server runs:
- The client knows the server's
/authorizeand/tokenendpoints — either via OAuth service discovery (/.well-known/oauth-authorization-server) or via static configuration shipped with the client. - The client generates a PKCE verifier (a random 43–128 char string) and a challenge (the SHA-256 hash of the verifier, base64url-encoded).
- The client opens the user's browser to
/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=.... - The user authenticates and grants consent; the server redirects back to the client's
redirect_uriwith a short-livedcodequery parameter. - The client POSTs to
/tokenwith thecodeplus the originalverifier; the server hashes the verifier, compares it to the stored challenge, and on match returns anaccess_tokenand arefresh_token. - The client attaches
Authorization: Bearer <access_token>to every subsequent MCP RPC. - The server validates the token on every request — signature, expiry, audience,
issuer — and extracts the user identity from the
subclaim.
The MCP spec is normative only at step 6: how the bearer attaches to the transport. On
Streamable HTTP it's the standard Authorization header on every JSON-RPC POST. On
stdio (used for local servers), the MCP spec defines a custom mechanism since there's no HTTP
layer to hang the header on — but in practice, local stdio servers usually skip OAuth
entirely and rely on the fact that the OS already authenticated the user before spawning the
process. Everything else in the flow — the endpoints, PKCE, the JWT validation —
is plain OAuth 2.1, and you can lean on existing libraries (Authlib, oauthlib, FastAPI's OAuth
helpers) to implement it.
Why we stub it
A production-grade OAuth provider is a multi-day project on its own. You need an issuer identifier, a discovery document, a JWKS endpoint that publishes your signing keys, a token signing pipeline with key rotation, refresh-token rotation, revocation lists, dynamic client registration (or a manual client-onboarding flow), and a way to log the user in to begin with. Doing all that inside a lesson about MCP would smother the actual lesson under unrelated identity-server plumbing.
So we stub it. pkb/auth.py exposes a single function,
get_current_user(ctx), that reads the principal from the PKB_USER
environment variable. Real deployments swap the body for a JWT verification step that pulls the
sub claim from a validated OAuth access token off ctx.request_context.
Crucially, the call sites — every tool that does
user = get_current_user(ctx) — don't change between the stub and the real
thing. That's the whole point of putting the integration point behind a function: when you
graduate from stub to OAuth, you edit one file.
The pkb/auth.py module
The module is intentionally tiny. Two functions, both backed by the same env-var stub:
from __future__ import annotations
import os
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INVALID_PARAMS
def get_current_user(ctx) -> str:
"""Return the authenticated user id, or raise McpError if missing.
Stub: reads PKB_USER from the environment. Real impl extracts the
`sub` claim from a validated OAuth access token off ctx.request_context.
"""
user = os.environ.get("PKB_USER", "").strip()
if not user:
raise McpError(ErrorData(
code=INVALID_PARAMS,
message="not authenticated (PKB_USER env var not set)",
))
return user
Two design notes. First, the error message names the env-var variable, never echoes
its value — we'll come back to that in the credential-leakage section. Second, the
function takes a ctx parameter even though the stub ignores it. That parameter
is the FastMCP Context object, and when the lesson upgrades from stub to real
OAuth the body of get_current_user will reach into
ctx.request_context (or whatever the equivalent surface is in the SDK version
you're targeting — the FastMCP API for request-scoped state is still settling) to pull
the validated identity. Passing ctx now keeps the call sites stable across that
future upgrade.
A require_user(ctx) wrapper can be added on top as syntactic sugar — same
body, returns the user, useful as the first line of every authed tool. PKB's call sites use
get_current_user(ctx) directly because the names are already self-documenting.
Per-user state
Once you have an authenticated principal, the global NOTES dict from Lesson 08
becomes a problem: every user shares the same notes. PKB refactors it into a per-user store:
USER_NOTES: dict[str, dict[str, str]] = {
"local": {
"hello": "Hello note body",
"todo": "Pick up groceries",
"ideas": "Build an MCP server tutorial",
}
}
# Legacy single-tenant alias used by L04-L08 examples. Points at the "local" bucket.
NOTES = USER_NOTES["local"]
Every tool that touches notes — search, fetch,
write, summarize, delete, the new export
— calls get_current_user(ctx) first and then scopes its work to
USER_NOTES[user]. New users get a default empty dict via
setdefault(user, {}).
The migration is mechanical, but it is also irreversible: once you ship a multi-tenant store, you can't go back to a global dict without a data-migration step that decides whose notes are whose. Plan for that the first time you swap the in-memory dict for a database, not the day a customer asks for it.
API-key handling
Third-party credentials follow a different shape. They aren't per-user — they belong to the server — and they live in environment variables or a secret manager, never in code, never in a config file checked into git. The canonical pattern is fail-fast at startup:
THIRD_PARTY_KEY = os.environ.get("PKB_THIRD_PARTY_KEY")
if not THIRD_PARTY_KEY:
raise SystemExit("PKB_THIRD_PARTY_KEY env var is required")
Three rules go with this. Never log them — not in DEBUG, not in
“just this once” print statements during a 3am incident. Never echo them
in responses — a tool that returns the SDK's internal request object will leak
the Authorization header straight to the caller. Never include them in
error messages — which is harder than it sounds, because many SDKs put the
request URL (with the key in a query string) into the exception text.
For production deployment, the env var doesn't come from a literal .env file on
disk; it comes from a secret manager — HashiCorp Vault, AWS Secrets Manager, GCP Secret
Manager, Kubernetes Secrets — that injects the value into the process environment at
container start. The application code is identical (os.environ.get(...)); only
the surrounding infrastructure changes.
Credential leakage
Three ways secrets escape, and how to plug each one
- Echoing exceptions in tool results. The pattern
return f"failed: {e}"looks innocent. It isn't. Many third-party SDKs put the full request URL into the exception message, and that URL often includes the API key as a query-string parameter. The exception then flows out to the LLM, the user, and your log pipeline. Solution: a_redact()helper that scrubs the known key from any string before it leaves the server, or better, structured errors that never include the raw exception text in the first place. - Debug logging of headers.
logger.debug("request headers: %r", request.headers)happily dumps theAuthorization: Bearer ...header into your log file. Log files end up in S3, end up indexed by your log-aggregation tool, end up on a developer's laptop. Solution: log a hash or a redacted form (Bearer [REDACTED]), never the raw header. - Tool responses containing raw HTTP responses.
return resp.json()seems convenient until you discover the third-party API echoes the callback URL (with the auth header in a query param) in its response body for “debugging”. The LLM gets the response, the user gets the response, the secret is now in the conversation transcript forever. Solution: explicitly select the fields you want to return; never blanket-forward a third-party response.
Local dev vs production
Local development uses a .env file (always gitignored) plus
python-dotenv to load it:
# mcp-servers/sample-server/python/.env
PKB_USER=local
# Future: PKB_THIRD_PARTY_KEY=...
The accompanying .gitignore rule is just .env, and you should sanity
check it with git check-ignore .env the first time you create the file. If that
command prints nothing, the file is not ignored and your next commit will publish your
development credentials to GitHub. (If that happens, rotate every leaked secret immediately;
“I'll force-push to remove it” is not a fix — GitHub caches every commit it
has ever seen.)
Production replaces the .env file with a secret manager of your choice. The
manager injects the env vars into the process environment at container start; the application
code reads os.environ.get(...) the same way it always has. Rotate via
deploy, never via config edits inside a running process — in-place edits leave
you with no audit trail, no rollback, and the old key still valid in the previous deployment's
cached environment.
References
- MCP specification — authorization — normative reference for the bearer-token attach point and the relationship to OAuth 2.1.
- OAuth 2.1 (draft) — the consolidated spec, including the PKCE mandate for public clients.
- The Twelve-Factor App — Config — rationale for env-var-based config and why secrets never live in code.
- python-dotenv — the library that loads
.envfiles intoos.environfor local development. - PKB sample-server tree — the working server at
mcp-servers/sample-server/python/where you wire inpkb/auth.pyduring this lesson's lab.