Lesson 09 Authentication & Secrets
Theme:

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:

  1. The client knows the server's /authorize and /token endpoints — either via OAuth service discovery (/.well-known/oauth-authorization-server) or via static configuration shipped with the client.
  2. The client generates a PKCE verifier (a random 43–128 char string) and a challenge (the SHA-256 hash of the verifier, base64url-encoded).
  3. The client opens the user's browser to /authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256&redirect_uri=....
  4. The user authenticates and grants consent; the server redirects back to the client's redirect_uri with a short-lived code query parameter.
  5. The client POSTs to /token with the code plus the original verifier; the server hashes the verifier, compares it to the stored challenge, and on match returns an access_token and a refresh_token.
  6. The client attaches Authorization: Bearer <access_token> to every subsequent MCP RPC.
  7. The server validates the token on every request — signature, expiry, audience, issuer — and extracts the user identity from the sub claim.

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

  1. 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.
  2. Debug logging of headers. logger.debug("request headers: %r", request.headers) happily dumps the Authorization: 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.
  3. 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