Auth

JWT/JWKS validation, public discovery, scope step-up (403 + WWW-Authenticate), session-hijacking prevention.

Auth Examples

Stable — MCP authorization (base spec): JWT/JWKS validation, Protected Resource Metadata, OAuth discovery, DCR.

MCP servers demonstrating mcpkit’s auth capabilities. No external dependencies — no Docker, no Keycloak. Each example spins up an in-process authorization server.

🚀 Skip to the guided walkthrough → — 8-step demokit walkthrough with sequence diagram covering public discovery, JWT/JWKS, scope step-up (403 + WWW-Authenticate), and session binding. Run it with just serve + just demo.

Quick Start

Start with the unified example — one server, all four auth patterns layered together:

cd examples/auth
go run ./unified

The server prints tokens and a step-by-step exercise walkthrough. Connect your MCP host to http://localhost:8080/mcp (Streamable HTTP).

What it demonstrates

  • Public discoverytools/list works without a token (per spec, capability discovery should be permitted pre-auth). Configured via server.WithPublicMethods(...).
  • JWT/JWKS validation — protected methods require Authorization: Bearer <RS256 JWT>; the MCP server fetches the AS’s JWKS and validates signatures via auth.NewJWTValidator.
  • Per-tool scope enforcementcore.ToolDef.RequiredScopes + auth.NewToolScopeMiddleware reject calls with insufficient scope. Spec-compliant HTTP 403 + WWW-Authenticate: Bearer error="insufficient_scope" for client-driven step-up.
  • Session binding — once a session is established with one user’s token, swapping tokens mid-session is rejected to prevent session hijacking. Subject captured at session creation; enforced in the streamable HTTP transport.
  • Layered usage — the unified example shows all four patterns running on a single server; the per-pattern sub-binaries (bearer/jwt/scopes/session-binding/public-discovery) are stripped-down fixtures for understanding each pattern in isolation.

Examples

Port Example Auth Pattern
8080 unified/ Start here — JWT + public discovery + scopes + session binding
8081 bearer/ Static bearer token (simplest possible)
8082 jwt/ RS256 JWT validation via JWKS
8083 scopes/ Scope-based access control
8084 session-binding/ Session hijacking prevention
8085 public-discovery/ Pre-auth tool discovery

Each sub-directory has its own README with setup, exercises, and copy-pasteable prompts.

Shared Infrastructure

The common/ package provides the auth building blocks all examples use:

Function What it does
common.NewEnv(scopes) Spins up an in-process authorization server with JWKS + token endpoint
env.NewValidator(audience) Creates a JWTValidator pointed at the AS
env.MintToken(subject, scopes) Mints a valid RS256 JWT
common.RegisterEchoTools(srv) Registers echo, write-tool, admin-tool with scope checks

MCP Host Configuration

See mcp.json for a ready-to-use multi-server configuration, or for the unified example:

{
  "mcpServers": {
    "auth-unified": {
      "type": "streamable-http",
      "url": "http://localhost:8080/mcp"
    }
  }
}
  • ext/auth/docs/DESIGN.md — Auth architecture
  • tests/e2e/ — E2E auth integration tests
  • tests/keycloak/ — Keycloak interop tests (real OIDC)

Next steps


Walkthrough

MCP Auth — Public Discovery + JWT + Scopes + Session Binding

Walks through auth patterns layered on a single mcpkit server: public method allowlist, JWT/JWKS validation, per-tool scope enforcement, and session hijacking prevention.

What you’ll learn

  • Discover server URL + minted tokens — The server pre-mints four tokens for the demo and exposes them via a non-standard /demo/bootstrap endpoint. In production a host would do OAuth (or accept tokens via mcp.json config); this shortcut keeps the demo focused on auth behavior.
  • Public discovery: tools/list without a token — The server is configured with WithPublicMethods(“initialize”, “notifications/initialized”, “tools/list”, “prompts/list”, “ping”). These bypass the auth check so an unauthenticated client can discover what’s available before requesting a token.
  • Protected method without a token → 401 — tools/call is NOT in the public allowlist. The mcpkit client surfaces this as *client.ClientAuthError. A real MCP host would use this to trigger an OAuth flow.
  • Call echo with alice’s read-only token (JWT validated via JWKS) — The mcpkit JWTValidator fetches the AS’s JWKS, verifies the RS256 signature using kid lookup, and exposes the claims to handlers via core.AuthClaims(ctx). echo is a no-scope tool that reflects the authenticated identity back, so we can see the validated claims.
  • Call write-tool with read-only token → 403 + insufficient_scope — write-tool declares RequiredScopes: [“write”] on its ToolDef. The auth.NewToolScopeMiddleware short-circuits the request with HTTP 403 + WWW-Authenticate before the handler runs (per SEP-2643 UC2 + RFC 6750). Scope info is in the header — the client’s RFC 6750 parser auto-populates RequiredScopes.
  • Reconnect with read+write token → write-tool succeeds — New session with the broader token. write-tool runs because the token includes write. Scope step-up in real systems is driven by the WWW-Authenticate response from the previous step — see examples/fine-grained-auth/ for the full SEP-2643 UC2 flow.
  • admin-tool with read+write token → 403 (needs admin) — admin-tool requires “admin” scope. The same scope-enforcement middleware returns 403 + WWW-Authenticate with the missing scope.
  • Session binding: bob’s token on alice’s session → rejected — mcpkit binds the principal (Claims.Subject) to the session at creation time. Subsequent requests on the same session must come from the same subject. Even though bob’s token is independently valid (correct signature, fresh, has all scopes), it doesn’t match alice’s bound session — so the request is rejected. This prevents an attacker who steals a session ID from using their own valid token to take over.

Flow

sequenceDiagram
    participant Host as MCP Host (this client)
    participant Server as MCP Server (just serve)
    participant AS as Auth Server (in-process)

    Note over Host,AS: Step 1: Discover server URL + minted tokens
    Host->>Server: GET /demo/bootstrap
    Server-->>Host: {mcp_url, tok_read, tok_read_write, tok_all, tok_bob}

    Note over Host,AS: Step 2: Public discovery: tools/list without a token
    Host->>Server: POST /mcp — initialize + tools/list (no Authorization header)
    Server-->>Host: tool list (3 tools, even without auth)

    Note over Host,AS: Step 3: Protected method without a token → 401
    Host->>Server: tools/call: echo  (no Authorization header)
    Server-->>Host: HTTP 401 + WWW-Authenticate

    Note over Host,AS: Step 4: Call echo with alice's read-only token (JWT validated via JWKS)
    Host->>Server: tools/call: echo + Bearer alice/[read]
    Server-->>Host: echo + claims (subject=alice, scopes=[read])

    Note over Host,AS: Step 5: Call write-tool with read-only token → 403 + insufficient_scope
    Host->>Server: tools/call: write-tool + Bearer alice/[read]
    Server-->>Host: HTTP 403 + WWW-Authenticate: Bearer error="insufficient_scope", scope="write"

    Note over Host,AS: Step 6: Reconnect with read+write token → write-tool succeeds
    Host->>Server: POST /mcp — initialize + Bearer alice/[read write]
    Server-->>Host: new session
    Host->>Server: tools/call: write-tool
    Server-->>Host: ok

    Note over Host,AS: Step 7: admin-tool with read+write token → 403 (needs admin)
    Host->>Server: tools/call: admin-tool + Bearer alice/[read write]
    Server-->>Host: HTTP 403 + WWW-Authenticate: scope="admin"

    Note over Host,AS: Step 8: Session binding: bob's token on alice's session → rejected
    Host->>Server: tools/call: echo + Mcp-Session-Id=<alice's> + Bearer bob/[all]
    Server-->>Host: HTTP 403 (subject mismatch)

Steps

Setup

Start the MCP server in a separate terminal first:

Terminal 1:  just serve        # MCP server + in-process AS on :8080
Terminal 2:  just run          # this demo

Auth patterns covered

  1. Public discoverytools/list works without a token (per spec, capability discovery should be permitted pre-auth).
  2. JWT authentication — protected methods require Authorization: Bearer <RS256 JWT>. The MCP server fetches the AS’s JWKS and validates signatures.
  3. Scope enforcementwrite-tool requires write scope; admin-tool requires admin. Missing scopes → HTTP 403 + WWW-Authenticate: Bearer error="insufficient_scope".
  4. Session binding — once a session is established with one user’s token, requests on that session must come from the same subject. Swapping tokens mid-session is rejected to prevent session hijacking.

Step 1: Discover server URL + minted tokens

The server pre-mints four tokens for the demo and exposes them via a non-standard /demo/bootstrap endpoint. In production a host would do OAuth (or accept tokens via mcp.json config); this shortcut keeps the demo focused on auth behavior.

Reproduce on the wire

# The demo server pre-mints tokens and serves them at /demo/bootstrap.
# Capture the MCP URL and tokens into shell vars used by every step below.
BOOT=$(curl -s http://localhost:8080/demo/bootstrap)
MCP=$(echo "$BOOT" | jq -r .mcp_url)
TOK_READ=$(echo "$BOOT" | jq -r .tok_read)
TOK_RW=$(echo "$BOOT" | jq -r .tok_read_write)
TOK_BOB=$(echo "$BOOT" | jq -r .tok_bob)
echo "MCP=$MCP"

Step 2: Public discovery: tools/list without a token

The server is configured with WithPublicMethods(“initialize”, “notifications/initialized”, “tools/list”, “prompts/list”, “ping”). These bypass the auth check so an unauthenticated client can discover what’s available before requesting a token.

Reproduce on the wire

# Mint a session (no Authorization header — initialize is public) and capture
# the session id, then call the public tools/list.
SID=$(curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq '.result.tools | length'

Step 3: Protected method without a token → 401

tools/call is NOT in the public allowlist. The mcpkit client surfaces this as *client.ClientAuthError. A real MCP host would use this to trigger an OAuth flow.

Reproduce on the wire

# Same unauthenticated session ($SID from the previous step). tools/call is
# NOT public, so the server replies 401 + WWW-Authenticate. -i shows headers.
curl -s -i -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}' \
  | grep -iE 'HTTP/|www-authenticate'

Step 4: Call echo with alice’s read-only token (JWT validated via JWKS)

The mcpkit JWTValidator fetches the AS’s JWKS, verifies the RS256 signature using kid lookup, and exposes the claims to handlers via core.AuthClaims(ctx). echo is a no-scope tool that reflects the authenticated identity back, so we can see the validated claims.

Reproduce on the wire

# Mint a fresh session carrying alice's read token, then call echo (no scope
# required). The server validates the RS256 JWT against the AS's JWKS.
SID=$(curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -H "Authorization: Bearer $TOK_READ" \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" -H "Authorization: Bearer $TOK_READ" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" -H "Authorization: Bearer $TOK_READ" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}' | jq '.result'

Step 5: Call write-tool with read-only token → 403 + insufficient_scope

write-tool declares RequiredScopes: [“write”] on its ToolDef. The auth.NewToolScopeMiddleware short-circuits the request with HTTP 403 + WWW-Authenticate before the handler runs (per SEP-2643 UC2 + RFC 6750). Scope info is in the header — the client’s RFC 6750 parser auto-populates RequiredScopes.

Reproduce on the wire

# Same read-token session ($SID). write-tool needs the "write" scope the token
# lacks → 403 + WWW-Authenticate: Bearer error="insufficient_scope", scope="write".
curl -s -i -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" -H "Authorization: Bearer $TOK_READ" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write-tool","arguments":{"data":"x"}}}' \
  | grep -iE 'HTTP/|www-authenticate'

Step 6: Reconnect with read+write token → write-tool succeeds

New session with the broader token. write-tool runs because the token includes write. Scope step-up in real systems is driven by the WWW-Authenticate response from the previous step — see examples/fine-grained-auth/ for the full SEP-2643 UC2 flow.

Reproduce on the wire

# New session with the read+write token; now write-tool succeeds.
SID_RW=$(curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -H "Authorization: Bearer $TOK_RW" \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID_RW" -H "Authorization: Bearer $TOK_RW" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
curl -s -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID_RW" -H "Authorization: Bearer $TOK_RW" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write-tool","arguments":{"data":"hello write"}}}' | jq '.result'

Step 7: admin-tool with read+write token → 403 (needs admin)

admin-tool requires “admin” scope. The same scope-enforcement middleware returns 403 + WWW-Authenticate with the missing scope.

Reproduce on the wire

# Same read+write session ($SID_RW). admin-tool needs "admin" → 403 +
# WWW-Authenticate: Bearer error="insufficient_scope", scope="admin".
curl -s -i -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID_RW" -H "Authorization: Bearer $TOK_RW" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"admin-tool","arguments":{"action":"rotate"}}}' \
  | grep -iE 'HTTP/|www-authenticate'

Step 8: Session binding: bob’s token on alice’s session → rejected

mcpkit binds the principal (Claims.Subject) to the session at creation time. Subsequent requests on the same session must come from the same subject. Even though bob’s token is independently valid (correct signature, fresh, has all scopes), it doesn’t match alice’s bound session — so the request is rejected. This prevents an attacker who steals a session ID from using their own valid token to take over.

Reproduce on the wire

# Replay alice's session id ($SID) but with bob's (independently valid) token.
# The session is bound to alice's subject, so the swap is rejected with 403.
curl -s -i -X POST "$MCP" \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" -H "Authorization: Bearer $TOK_BOB" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hijack attempt"}}}' \
  | grep -iE 'HTTP/'

Where each pattern lives in the code

Run it

go run ./examples/auth/

Pass --non-interactive to skip pauses:

go run ./examples/auth/ --non-interactive