Auth
JWT/JWKS validation, public discovery, scope step-up (403 + WWW-Authenticate), session-hijacking prevention.
Source: examples/auth/README.md
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 discovery —
tools/listworks without a token (per spec, capability discovery should be permitted pre-auth). Configured viaserver.WithPublicMethods(...). - JWT/JWKS validation — protected methods require
Authorization: Bearer <RS256 JWT>; the MCP server fetches the AS’s JWKS and validates signatures viaauth.NewJWTValidator. - Per-tool scope enforcement —
core.ToolDef.RequiredScopes+auth.NewToolScopeMiddlewarereject calls with insufficient scope. Spec-compliantHTTP 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"
}
}
}
Related ¶
ext/auth/docs/DESIGN.md— Auth architecturetests/e2e/— E2E auth integration teststests/keycloak/— Keycloak interop tests (real OIDC)
Next steps ¶
Walkthrough
Generated from the scripted demo — run it with make serve + make demo. Source: examples/auth/WALKTHROUGH.md
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 ¶
- Public discovery —
tools/listworks without a token (per spec, capability discovery should be permitted pre-auth). - JWT authentication — protected methods require
Authorization: Bearer <RS256 JWT>. The MCP server fetches the AS’s JWKS and validates signatures. - Scope enforcement —
write-toolrequireswritescope;admin-toolrequiresadmin. Missing scopes → HTTP 403 +WWW-Authenticate: Bearer error="insufficient_scope". - 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 ¶
- Public methods:
server.WithPublicMethods(...) - JWT/JWKS validation:
auth.NewJWTValidator(JWTConfig{JWKSURL: ...})—ext/auth/jwt_validator.go - Per-tool scopes:
core.ToolDef.RequiredScopes+auth.NewToolScopeMiddleware(reg)—ext/auth/scope_middleware.go - Session binding: enforced in
server/streamable_transport.go(verifyPrincipal); subject is captured at session creation
Run it ¶
go run ./examples/auth/
Pass --non-interactive to skip pauses:
go run ./examples/auth/ --non-interactive