Reverse proxy in Go acting as an OAuth 2.1 Authorization Server compatible with the MCP auth spec, federating authentication to any OIDC Identity Provider (Keycloak, Microsoft Entra ID, Auth0, Okta, Google...) via auto-discovery. It lets MCP clients (claude.ai, Claude Code) access a private MCP server through a standard PKCE flow.
Companion docs: docs/conformance.md (RFC claim matrix + IdP evidence), docs/threat-model.md (STRIDE coverage with code + test + runbook links).
This proxy MUST conform to the following specifications:
| Spec | Usage |
|---|---|
| OAuth 2.1 (draft-ietf-oauth-v2-1-13) | Authorization code + PKCE flow, token handling, security requirements. Draft-13 is the version pinned by MCP Authorization 2025-06-18; the IETF OAuth WG has since published draft-15 — deltas tracked separately and not yet adopted here |
| RFC 8414 — OAuth 2.0 Authorization Server Metadata | GET /.well-known/oauth-authorization-server discovery endpoint |
| RFC 7591 — OAuth 2.0 Dynamic Client Registration | POST /register — automatic client registration for MCP clients |
| RFC 9728 — OAuth 2.0 Protected Resource Metadata | GET /.well-known/oauth-protected-resource — MCP clients discover the AS through this endpoint; WWW-Authenticate header on 401 responses MUST include resource_metadata URL |
| RFC 8707 — Resource Indicators for OAuth 2.0 | resource parameter accepted in /authorize and /token requests |
| RFC 7636 — PKCE | code_verifier must be 43-128 characters, code_challenge_method must be S256 |
| MCP Authorization Spec (2025-06-18) | End-to-end MCP auth flow combining the above RFCs |
- Claude's OAuth callback URL:
https://claude.ai/api/mcp/auth_callback(may migrate tohttps://claude.com/api/mcp/auth_callback) - Claude's OAuth client name:
Claude - Claude supports Dynamic Client Registration (DCR)
- Claude supports both SSE and Streamable HTTP transports (SSE may be deprecated)
- Claude supports token expiry and refresh
MCP Client (claude.ai / Claude Code)
│
│ 0. MCP request → 401 + WWW-Authenticate header
│ 1. GET /.well-known/oauth-protected-resource (RFC 9728)
│ 2. GET /.well-known/oauth-authorization-server (RFC 8414)
│ 3. POST /register (RFC 7591 Dynamic Client Registration)
│ 4. GET /authorize (PKCE + resource param)
│ 5. POST /token (+ resource param)
│ 6. MCP requests with Bearer token
▼
mcp-auth-proxy (this service)
│
│ Federates auth → OIDC IdP (Keycloak, Entra, Auth0...)
│ Validates incoming Bearer tokens
│ Forwards requests to upstream MCP server
▼
Upstream MCP Server (target, unmodified)
All transient OAuth state (client registrations, authorize sessions, authorization codes, refresh tokens) is AES-GCM encrypted into the tokens and URL parameters themselves. Any instance sharing the same TOKEN_SIGNING_SECRET can handle any request — no shared storage, no sticky sessions.
| Flow state | Encrypted into | Carries audience? |
|---|---|---|
| Client registration | client_id (encrypted blob, 7d default TTL — configurable via CLIENT_REGISTRATION_TTL) |
yes |
| Authorize session | IdP state parameter (encrypted blob, 10min TTL) |
yes |
| Authorization code | code parameter (encrypted blob, 60s TTL) |
yes |
| Access token | Opaque token (encrypted claims, 1h TTL; at most 1h in forwarding mode, min(1h, IdP expiry − 60 s)) |
yes |
| Refresh token | Opaque token (encrypted claims + iat, 7d TTL) |
yes |
| IdP refresh / access token (forwarding mode only) | the authorization code (refresh token only), the refresh token (refresh token) and the access token (access token) — see Upstream IdP token forwarding |
inherited from the carrier |
Every sealed payload carries the proxy's PROXY_BASE_URL as an audience field, populated at creation time and verified on every open. Two deployments accidentally sharing the same TOKEN_SIGNING_SECRET (e.g. by copy-pasted Helm values, mirrored DR configs, or a shared Secret) cannot replay each other's tokens — the receiving instance rejects any payload whose audience does not match its own PROXY_BASE_URL.
The check is enforced in:
middleware/auth.go:Validate— access token bearer checkhandlers/authorize.go—sealedClientopenhandlers/callback.go—sealedSessionopen (before the IdP exchange runs)handlers/token.go:handleAuthorizationCode—sealedCodeandsealedClientopenhandlers/token.go:handleRefreshToken—sealedRefreshandsealedClientopen
Within a single deployment this is invisible: the audience always matches and nothing changes for clients. The cost is one string comparison per check.
Trade-offs:
- No per-token revocation without a shared store. Mitigated by short access token TTL (1h), PKCE preventing code replay, and
REVOKE_BEFOREfor bulk revocation. - Authorization codes are replayable within their 60-second TTL when no replay store is configured. Mitigated by PKCE (the attacker also needs the
code_verifier) and the short window. SetREDIS_URLto make codes strictly single-use across replicas (RFC 6749 §4.1.2). - Bulk revocation via
REVOKE_BEFORE: set to the current timestamp and redeploy — all existing access tokens AND refresh tokens withiatbefore the cutoff are rejected. Refresh tokens carry their owniatso an attacker holding a leaked refresh cannot keep minting fresh access tokens past the cutoff. Incident response: rotateREVOKE_BEFOREand watch akubectl rollout statuscomplete before assuming the cutoff is enforced fleet-wide.
REDIS_REQUIRED defaults to true: the proxy fails startup with Fatal if REDIS_URL is unset. Stateless mode is an explicit opt-out (REDIS_REQUIRED=false) for dev or single-replica deployments that accept the trade-off.
Set REDIS_URL (e.g. redis://redis:6379/0, or rediss:// for TLS) to enable two layered protections backed by Redis SET NX / EXISTS. All Redis keys are namespaced with REDIS_KEY_PREFIX (default mcp-auth-proxy:) so multiple proxy deployments can safely share a single Redis DB without key collisions:
- Single-use authorization codes. Each code carries a unique
tid(UUID);/tokenclaims the key atomically, so a second exchange attempt is rejected withinvalid_grant+error_code: code_replay. Claim TTL matches the remaining code lifetime. - Refresh rotation with reuse detection (RFC 6749 §10.4 / OAuth 2.1 §6.1). Each refresh carries a unique
tidand afam(family ID). Thefamis seeded at/callback(on the sealed code) and inherited by every refresh that descends from it, so a replayed authorization code (per RFC 6749 §4.1.2) and a replayed refresh both target the same family marker — the legitimate holder and the attacker are revoked together. On rotation the oldtidis claimed; replaying an already-rotated token past theREFRESH_RACE_GRACE_SECwindow is detected as reuse, revokes the whole family (refresh_family_revoked:<fam>marker, 7-day TTL), and any subsequent use of any sibling refresh is rejected witherror_code: refresh_family_revoked. The compromised lineage stops minting tokens; both parties are forced back through/authorize. A racing second submit inside the grace window (parallel-tab refresh, slow-network double-submit) returns 429refresh_concurrent_submitinstead — the legitimate peer's rotation already succeeded, no family is killed, the racing client retries with the new refresh once it lands.
On Redis failure the handler fails closed (503 server_error / error_code: replay_store_unavailable) rather than issuing tokens against an unknown replay state. When REDIS_URL is unset the proxy stays fully stateless: codes remain replayable within the 60s TTL (mitigated by PKCE), and refresh tokens rotate without reuse detection. The exception is UPSTREAM_FORWARD_IDP_TOKEN=true, which then uses an in-memory replay store (single instance only).
Defaults were tightened to enforce the strict OAuth 2.1 / MCP posture. An operator pulling a new image without re-reading the config table will hit a hard Fatal at startup for most of these; RENDER_CONSENT_PAGE instead changes the shape of the /authorize response.
UPSTREAM_MCP_URLnow requires an explicit path. Origin-only URLs (http://backend,http://backend/) used to be the only legal shape; they are now rejected. The path is the proxy's public mount AND the path forwarded upstream — pick what your upstream actually serves (FastMCP default:/mcp). The path is also restricted to RFC 3986 unreserved characters plus/, so:,*,{,},@,+etc. are rejected — they would otherwise silently register chi router patterns instead of literal segments.PROD_MODEnow defaults totrue. Wasfalse. Strict mode rejects every relaxation flag (PKCE_REQUIRED=false,COMPAT_ALLOW_STATELESS=true,REDIS_REQUIRED=false,REDIS_URLempty,OIDC_ALLOW_INSECURE_HTTP=true, a weakTOKEN_SIGNING_SECRET, legacyTRUST_PROXY_HEADERS=truewithoutTRUSTED_PROXY_CIDRS;RATE_LIMIT_ENABLED=falsejoined the list later, see below). Existing dev / single-replica deployments that depended on those flags must setPROD_MODE=falseexplicitly.RENDER_CONSENT_PAGEnow defaults totrue. Wasfalse./authorizenow returns a 200 HTML consent page instead of a 302 to the IdP; the user clicks Approve or Deny on a<form action="/consent" method="POST">. Closes the open-DCR-plus-active-IdP-session silent-issuance phishing class. Browser-driven MCP clients (claude.ai, Claude Code, Cursor, MCP Inspector, ChatGPT) follow the form transparently. If you drive/authorizefrom a non-browser caller (CI rig, scrape test, headless agent that expected a 302), you have two options: setRENDER_CONSENT_PAGE=falseto keep the legacy silent redirect, or update the caller to handle a 200 HTML response with aconsent_token-bearing form (seekeycloak_e2e_test.go::approveConsentfor the reference walk-through).POST /consentwith anAuthorizationheader now answers 400, not 401. A challenge-less 401 violates RFC 7235 §3.1, and a challenge would pop a browser credential dialog on an endpoint that rejects credentials by design. Any harness asserting 401 there breaks./authorize,/consentand/callbacknow answertext/htmlto a caller sendingAccept: text/html. Many HTTP clients send that by default, so a non-browser caller that previously received JSON now receives a ~1.3 kB page on the same status and with the sameerror/error_code. SendAccept: application/json(or noAccept) to keep the JSON body./token,/registerand discovery are unaffected whatever they ask for.- Wrong-method and unrouted requests now carry a body. chi's defaults returned an empty 405 and a
text/plain404; both now answer JSON, or the error page on a browser-facing path with a real navigation. - Request headers are capped at 64 KB on both listeners (
MaxHeaderBytes), down from net/http's 1 MB default. Over that, net/http answers431 Request Header Fields Too Largebefore any middleware, so it appears in no access log and no metric. Thegroupsclaim inside an access token is capped at mint time (GROUPS_CLAIM_MAX_BYTES, default 32 KB, range[1 KB, 40 KB]) to keep the proxy's own bearer token inside that budget — watchmcp_auth_groups_claim_truncated_total. PROD_MODE=truenow also rejectsRATE_LIMIT_ENABLED=false. The per-IP buckets are the only bound on the unauthenticated pre-auth surface, and the browser-facing error page makes a typical rejection ~13x the JSON body it replaced (~16x on the throttle path). A production pod running both settings now fails startup; keep the limiter on, or setPROD_MODE=falseif a WAF genuinely enforces the bound upstream.
All configuration is via environment variables.
| Variable | Description | Example |
|---|---|---|
OIDC_ISSUER_URL |
OIDC Issuer URL (auto-discovery via /.well-known/openid-configuration) |
https://keycloak.example.com/realms/myrealm or https://login.microsoftonline.com/{tenant}/v2.0 |
OIDC_CLIENT_ID |
OIDC client ID registered with the IdP | xxxxxxxx-... |
OIDC_CLIENT_SECRET |
OIDC client secret | ... |
PROXY_BASE_URL |
Public URL of this proxy | https://mcp-proxy.example.com |
UPSTREAM_MCP_URL |
Upstream MCP URL. Path is mandatory and is used verbatim as both the proxy's public mount AND the path forwarded upstream. Query, fragment, userinfo, origin-only URLs (no path / lone /), and paths that collide with a reserved control-plane route (/healthz, /register, /authorize, /callback, /token, /.well-known) are rejected at startup. |
http://mcp-server:8080/mcp |
LISTEN_ADDR |
Bind address | :8080 |
METRICS_ADDR |
Prometheus metrics bind address. Default 127.0.0.1:9090 — loopback only so /metrics and /readyz are never exposed on the public interface. Override to :9090 or a specific interface when a Prometheus scraper must reach the pod over the network |
127.0.0.1:9090 (default) |
TOKEN_SIGNING_SECRET |
Secret for AES-GCM opaque tokens (min 32 bytes, shared across all instances) | ... |
LOG_LEVEL |
debug, info, warn |
info |
GROUPS_CLAIM |
Flat claim name in the OIDC id_token containing user groups | groups (default) |
ALLOWED_GROUPS |
Comma-separated group allowlist. Empty = allow all authenticated users | admin,mcp-users |
REVOKE_BEFORE |
RFC3339 timestamp — both access tokens AND refresh tokens with iat before this are rejected (bulk revocation). Empty = disabled |
2026-03-28T12:00:00Z |
PKCE_REQUIRED |
Require PKCE on /authorize (default true). Set false for Cursor, MCP Inspector, ChatGPT compat |
true |
SHUTDOWN_TIMEOUT |
Graceful shutdown deadline. Raise above the longest expected SSE stream so rolling deploys do not cut MCP sessions mid-stream. Match terminationGracePeriodSeconds in K8s |
120s (default) |
REDIS_URL |
Enables single-use authorization codes and refresh rotation with reuse detection (OAuth 2.1 §6.1) across replicas. rediss:// for TLS. On Redis failure the proxy fails closed (503) |
redis://redis:6379/0 |
REDIS_REQUIRED |
Fail startup (logger.Fatal) when REDIS_URL is unset. Default true — stateless mode leaves authorization codes / refresh tokens replayable within their TTL (findings C3/C4). Set false only for dev or single-replica deployments that accept the trade-off |
true (default) |
REDIS_KEY_PREFIX |
Prefix applied to every Redis key (default mcp-auth-proxy:). Override when sharing a Redis DB between multiple proxy deployments to avoid key collisions. Set explicitly to empty (REDIS_KEY_PREFIX=) to opt out of namespacing |
prod-mcp: |
RATE_LIMIT_ENABLED |
Per-IP rate limiting on pre-auth endpoints and on the authenticated MCP route (default true). Keyed on the stripped RemoteAddr by default; set TRUST_PROXY_HEADERS=true to honor X-Forwarded-For/X-Real-IP/True-Client-IP behind a trusted frontend |
true |
TRUST_PROXY_HEADERS |
Legacy blanket trust of X-Forwarded-For/X-Real-IP/True-Client-IP when keying the rate limiter (default false). Prefer TRUSTED_PROXY_CIDRS; PROD_MODE=true rejects this flag unless CIDRs are configured, otherwise a direct client can trivially mint its own rate-limit key and bypass the limiter |
false (default) |
MCP_PER_SUBJECT_CONCURRENCY |
Per-subject in-flight request cap on the authenticated MCP route (default 16). A runaway or compromised client identity cannot saturate the proxy / upstream pool at the expense of others. Entries for subjects with no in-flight work are reclaimed by a background pruner after ≥5 min idle so map memory stays proportional to active principals, not the lifetime set of ever-seen subjects. 0 disables the limit. Excess requests return 503 temporarily_unavailable with Retry-After: 1 and increment mcp_auth_access_denied_total{reason="subject_concurrency_exceeded"} |
16 (default) |
COMPAT_ALLOW_STATELESS |
When true, /authorize synthesizes a state server-side if the client omits it (legacy MCP Inspector / Cursor). Default false — strict mode refuses with 400 invalid_request because a silent server-synth hides client-side CSRF bugs. mcp_auth_access_denied_total{reason="state_missing"} is incremented either way so operators can see how many clients still rely on the compat path |
false (default) |
MCP_LOG_BODY_MAX |
Max bytes buffered per authenticated request for JSON-RPC method extraction into access logs (default 65536). 0 disables buffering — no rpc_method/rpc_tool/rpc_id fields are emitted. Only triggered when Content-Type: application/json and Content-Length is set and within the limit; SSE / chunked uploads pass through untouched |
65536 (default) |
ACCESS_LOG_SKIP_RE |
Go RE2 regexp matched against r.URL.Path on the public listener only. Matching paths are dropped from the access log; handler response, Prometheus counters, and panic recovery are unaffected. Compiled once at startup; invalid pattern is fatal. RE2 is linear-time — no ReDoS surface. Whitespace-only values are treated as unset. /readyz and /metrics live on METRICS_ADDR and never reach this middleware. Always anchor with ^…$; unanchored substrings can match unrelated upstream paths and .* silences the entire access log |
^/healthz$ |
GROUPS_CLAIM_MAX_BYTES |
Byte budget for the groups claim sealed into an access token; excess dropped at mint time with a groups_claim_truncated WARN naming the subject. Range [1024, 40960] — the ceiling is measured, since the seal expands the claim ~1.39x and 40 KB of groups already fills 56 KB of the 64 KB header block. With UPSTREAM_FORWARD_IDP_TOKEN=true the IdP access token's length comes off this budget first |
32768 |
PROD_MODE |
Strict-posture gate. Default true — fails startup if any compatibility flag that weakens a security control is set (PKCE_REQUIRED=false, COMPAT_ALLOW_STATELESS=true, REDIS_REQUIRED=false, RATE_LIMIT_ENABLED=false, REDIS_URL empty, OIDC_ALLOW_INSECURE_HTTP=true, a weak TOKEN_SIGNING_SECRET, or legacy TRUST_PROXY_HEADERS=true without TRUSTED_PROXY_CIDRS). Set PROD_MODE=false explicitly for dev / single-replica work that needs one of the relaxation toggles |
true (default) |
TRUSTED_PROXY_CIDRS |
Comma-separated CIDRs of peers whose X-Forwarded-For/X-Real-IP/True-Client-IP headers are honored for rate-limit keying. Other peers fall back to RemoteAddr. Preferred over TRUST_PROXY_HEADERS; takes precedence when both are set |
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 |
MCP_RESOURCE_NAME |
Optional human-readable display name advertised under resource_name in the RFC 9728 PRM. Used by MCP clients for consent / UI display. Field is omitted when unset |
ACME MCP |
UPSTREAM_AUTHORIZATION_HEADER |
When non-empty, sent verbatim as the Authorization header on every request to the upstream MCP backend (full value incl. scheme, e.g. Bearer s3cr3t). Treat as a secret — mount from a Secret, not a ConfigMap. Mutually exclusive with UPSTREAM_FORWARD_IDP_TOKEN |
Bearer xyz |
UPSTREAM_FORWARD_IDP_TOKEN |
true forwards the signed-in user's IdP access token to the upstream as Authorization: Bearer. Always runs with a replay store: REDIS_URL, required under PROD_MODE (startup fails without it). Outside PROD_MODE, with REDIS_REQUIRED=false and no REDIS_URL, the proxy uses an in-memory store and logs replay_store_in_memory: single instance only, state lost on restart. Mutually exclusive with UPSTREAM_AUTHORIZATION_HEADER. See Upstream IdP token forwarding |
false (default) |
OIDC_EXTRA_SCOPES |
Space-separated scopes appended to openid email profile on the IdP authorize request and, in forwarding mode, on every IdP refresh request. Operator-only: clients still cannot request scopes |
empty (default) |
TOKEN_SIGNING_SECRETS_PREVIOUS |
Whitespace-separated retired signing secrets accepted on Open during a rolling rotation. New seals always use TOKEN_SIGNING_SECRET (primary); Open tries primary first, then each previous entry. Each entry must be ≥32 bytes |
<old1> <old2> |
LOG_LEVEL |
Zap log level (debug / info / warn / error) |
info (default) |
GROUPS_CLAIM |
Flat claim name in id_token that carries user group memberships | groups (default) |
ALLOWED_GROUPS |
Comma-separated allowlist; empty = allow all authenticated users | admin,mcp-users |
mcp-auth-proxy/
├── main.go
├── go.mod
├── go.sum
├── config/
│ └── config.go # env parsing, validation
├── handlers/
│ ├── helpers.go # OAuthError + error_code vocabulary, the error sink, sealed types, isLoopback
│ ├── pages.go # shared page styles, CSPs, buffered render path
│ ├── error_page.go # Accept negotiation, title/advice mapping, pre-rendered 429/405/404 responders
│ ├── consent.go # consent page + POST /consent + nav interstitial
│ ├── resource_metadata.go # GET /.well-known/oauth-protected-resource (RFC 9728)
│ ├── discovery.go # GET /.well-known/oauth-authorization-server (RFC 8414)
│ ├── register.go # POST /register (RFC 7591 DCR)
│ ├── authorize.go # GET /authorize (+ resource param, RFC 8707)
│ ├── callback.go # GET /callback (OIDC IdP return)
│ └── token.go # POST /token (+ resource param, RFC 8707)
├── middleware/
│ └── auth.go # Bearer token validation on MCP routes
├── proxy/
│ └── proxy.go # reverse proxy to upstream MCP server
├── token/
│ └── token.go # AES-GCM seal/open, access token issue/validate
├── replay/
│ ├── replay.go # Store interface + ErrAlreadyClaimed
│ ├── redis.go # Redis-backed Store (SET NX, SET, EXISTS with prefix)
│ └── memory.go # In-process Store (tests / single replica)
├── internal/
│ ├── health/ # readiness probe state
│ └── subjectlimiter/ # per-subject in-flight cap on the MCP route
├── middleware/
│ ├── auth.go # Bearer validation on the proxied MCP route
│ └── rpc_peek.go # JSON-RPC method/tool extraction for logs + metrics
├── metrics/
│ └── metrics.go # Prometheus counters for security events
└── Dockerfile
See go.mod — kept inline previously, now the source of truth lives next to the code.
Response 200 JSON:
{
"resource": "{PROXY_BASE_URL}/",
"authorization_servers": ["{PROXY_BASE_URL}"],
"bearer_methods_supported": ["header"],
"scopes_supported": [],
"resource_name": "{MCP_RESOURCE_NAME if set}"
}MCP clients use this endpoint to discover which authorization server protects this resource. No authentication required.
Intentional deviation from RFC 9728 §3. The spec reads the resource value at the origin-root PRM as the identifier into which the well-known suffix was inserted — i.e. {PROXY_BASE_URL} without the trailing slash. We instead advertise {PROXY_BASE_URL}/ because Claude.ai canonicalizes RFC 8707 resource indicators with a trailing slash; stripping the slash here would cause resource-param equality checks on every /authorize and /token call from Claude.ai to fail. matchResource (handlers/helpers.go) is trailing-slash insensitive, so clients that send the spec-strict form without the slash still validate correctly. Strict-spec clients that want the canonical form should fetch the per-mount variant below, which advertises exactly {PROXY_BASE_URL}<mount> with no suffix.
scopes_supported is emitted as an empty array: the proxy has no scope model (scopes are not parsed at /authorize, not sealed into access tokens, not checked by the RS middleware). Publishing [] is more informative than omitting — least-privilege-aware clients see a concrete "no scopes" signal rather than having to probe.
resource_name is advertised when MCP_RESOURCE_NAME is set; omitted otherwise. Clients use it for consent / UI display.
Where <mount> is the path component of UPSTREAM_MCP_URL (e.g. /mcp, /api/v1/mcp). Same shape, resource = {PROXY_BASE_URL}<mount>. This variant is spec-strict: the resource value matches the identifier into which the well-known suffix was inserted, no trailing slash. MCP clients that follow RFC 9728 §3.1 per-resource discovery fetch this path and get the canonical form.
Also served at /.well-known/oauth-authorization-server<mount> (non-spec compat for MCP clients that probe the per-resource suffix), where <mount> is the path component of UPSTREAM_MCP_URL (e.g. /mcp, /api/v1/mcp). Both paths return the same document.
Response 200 JSON:
{
"issuer": "{PROXY_BASE_URL}",
"authorization_endpoint": "{PROXY_BASE_URL}/authorize",
"token_endpoint": "{PROXY_BASE_URL}/token",
"registration_endpoint": "{PROXY_BASE_URL}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": []
}PKCE-only proxy: no client secrets are validated. scopes_supported is an explicit empty array — the proxy carries no scope model (see PRM note above). No authentication required on this endpoint.
Request body (JSON):
{
"redirect_uris": ["https://claude.ai/..."],
"client_name": "Claude",
"token_endpoint_auth_method": "none"
}Behavior:
- Validate that
redirect_urisis present and non-empty - Reject
client_namelonger than 512 bytes so unauthenticated registrations cannot amplify into oversized logs or sealedclient_idresponses - OAuth 2.1 §2.3.1: each
redirect_urimust use HTTPS, or HTTP when pointing at a loopback host. Loopback is recognized vianet.ParseIP().IsLoopback()(covers the full 127/8 range,::1,::ffff:127.0.0.1,::0.0.0.1) plus the literallocalhost/localhost.. Non-http(s) schemes (e.g.ftp://,ldap://,file://, custom app schemes) are rejected unconditionally even when the host is loopback - Generate an internal UUID for the client
- Encrypt the whole
{ id, redirect_uris, client_name, expires_at }with AES-GCM → this is the returnedclient_id - TTL embedded in the encrypted blob: 7d default, configurable via
CLIENT_REGISTRATION_TTL(Go duration, capped at 90d;0= never expires, sealing a zeroexpires_at). The 7d default matchesrefreshTokenTTLso a client holding a still-valid refresh token can always exchange it — a shorter TTL silently kills long-running MCP clients (which treat DCR as one-shot at startup) the moment their access token first expires.0is the opt-in escape hatch for clients that never re-run DCR oninvalid_client(e.g. Azure APIM connectors); it trades away the reuse-window bound on an exfiltratedclient_id. - Request body limited to 1 MB (
MaxBytesReader)
Response 201 JSON:
Headers: Cache-Control: no-store, Pragma: no-cache.
{
"client_id": "<encrypted blob>",
"client_id_issued_at": 1234567890,
"client_id_expires_at": 1234654290,
"redirect_uris": ["..."],
"client_name": "<echoed if submitted>",
"token_endpoint_auth_method": "none"
}client_id_expires_at (an RFC 7591 extension field — the spec defines client_secret_expires_at, not client_id_expires_at) is the UNIX timestamp at which the sealed client_id stops opening (default client_id_issued_at + 7d, configurable via CLIENT_REGISTRATION_TTL). Clients that cache the handle should re-register before this time to avoid a 400 on /authorize and on /token refresh-token rotations. When CLIENT_REGISTRATION_TTL=0, client_id_expires_at is emitted as 0 ("never expires", mirroring RFC 7591 §3.2.1 client_secret_expires_at) and the expiry check is skipped on every validation path.
Error responses use RFC 7591 §3.2.2 codes: invalid_redirect_uri for any redirect_uri-shape defect (missing, over-count, over-length, malformed, opaque, hostless, fragment-bearing, userinfo-bearing, or non-https-non-loopback); invalid_client_metadata for unsupported token_endpoint_auth_method, over-length client_name, or client_name containing control bytes (NUL/CR/LF/TAB) or the X-User-Groups delimiter , (the field is sealed into the returned client_id and emitted to logs; raw control bytes would smuggle past zap's JSON-escaping when downstream code unsealed and parsed it); invalid_request for structural problems: 400 with "invalid JSON body" for a malformed body, 413 with "request body exceeds the 1 MB cap" when the body crosses MaxBytesReader.
Query params:
response_type=code(required, reject otherwise)client_id(required, decrypt and validate not expired)redirect_uri(required, must match a registered URI — exact match)code_challenge(required ifPKCE_REQUIRED=true, optional otherwise; 43-128 unreserved characters per RFC 7636)code_challenge_method=S256(required ifcode_challengepresent)state(required by default; strict mode rejects/authorizewith 400invalid_requestwhen absent. SetCOMPAT_ALLOW_STATELESS=trueto keep the legacy server-synth behavior for Cursor / MCP Inspector —mcp_auth_access_denied_total{reason="state_missing"}is incremented either way for visibility)resource(optional, RFC 8707 — accepted when it matches either{PROXY_BASE_URL}/{PROXY_BASE_URL}/or the configured mount resource{PROXY_BASE_URL}<mount>)
Behavior:
- Validate all params; reject repeated singleton params (
resourcemay appear more than once per RFC 8707); everyresourcevalue must match an accepted resource URI - Decrypt the
client_id→ verify not expired,redirect_urimatched - Consent fork. The validated parameters are sealed into a
sealedConsentblob (PurposeConsentAAD, audience-bound, 5-min TTL) and an HTML page is rendered showing the registeredclient_name, the parsed redirect host, and the resource. The user clicks Approve or Deny. The form POSTs to/consent(see below). Default behavior; settingRENDER_CONSENT_PAGE=falseskips this step and runs the silent redirect at step 4 directly — only safe when every caller is non-interactive and known-trusted. - Encrypt the session with AES-GCM (10min TTL):
{ client_id (internal UUID), redirect_uri, code_challenge, original_state, expires_at } - Use the encrypted blob as the
stateparameter sent to the IdP - Build the authorization URL from endpoints discovered via OIDC auto-discovery:
{discovered_authorization_endpoint} ?client_id={OIDC_CLIENT_ID} &response_type=code &redirect_uri={PROXY_BASE_URL}/callback &scope=openid email profile {OIDC_EXTRA_SCOPES when set} &state={encrypted_session} &response_mode=query - Redirect 302 to the IdP
Always registered. Driven by the consent form rendered at step 3 of /authorize (default behaviour; bypassed only when the operator sets RENDER_CONSENT_PAGE=false).
Form fields: consent_token (sealed blob from the GET-side render), action (approve or deny).
Guards (same shape as /token, except the Authorization rejection):
r.URL.RawQuery != ""→ 400. The sealed token would otherwise leak into access logs / browser history / Referer.Authorizationheader present → 400invalid_request+error_code=consent_auth_header_present, no challenge. The endpoint advertises no client-auth scheme; a 401 would require aWWW-Authenticatechallenge (RFC 7235 §3.1) that pops a browser credential dialog on an endpoint rejecting credentials by design.- Body capped at 1 MB; repeated
consent_token/actionfields rejected.
Behavior:
- Open
consent_token(PurposeConsentAAD); reject on bad shape, wrong purpose, audience mismatch, or expired (5 minTTL). action=deny: incrementmcp_auth_consent_decisions_total{decision="denied"}, logconsent_denied, answer with the navigation interstitial (200 HTML,meta http-equiv="refresh", no JS) targeting the registeredredirect_uriwitherror=access_deniedper RFC 6749 §4.1.2.1.action=approve: mint upstream OIDCnonce(random 32 hex) + upstream PKCE verifier, regenerate the H6 server-side PKCE pair if the consent blob recorded one, seal asealedSession(same shape as step 4 of/authorize), answer with the navigation interstitial targeting the IdP authorize URL. Incrementmcp_auth_consent_decisions_total{decision="approved"}.- Anything else: 400
invalid_request.
Why an interstitial, not a 302: Chromium enforces the consent page's CSP form-action directive against every hop of the redirect chain a form submit initiates — including redirects the OAuth client performs after receiving the code, which are unknowable in advance. Terminating the form navigation at a same-origin 200 ends that enforcement; the meta refresh starts a regular navigation that form-action does not govern. This keeps the consent page CSP at form-action 'self' with no origin enumeration. The interstitial's own CSP is fully locked (form-action 'none', no scripts) and Cache-Control: no-store. Server-error paths (seal failure, nonce failure) deliver the RFC 6749 error envelope through the same interstitial; only the replay-store-unavailable 503 and pre-validation 4xx stay at the proxy (same-origin, no redirect to block) — and since /consent is BrowserFacing, those render as the negotiated error page for a browser and as JSON for everyone else.
Single-use replay defense on consent_token: when a replay store is wired (REDIS_URL), the consent token's jti is claimed atomically before either branch runs — the decision (Approve or Deny) executes at most once per token. Each GET /authorize render mints a fresh jti, so the back-button case still works. On replay (double-submit, back-button re-POST, captured token): the proxy re-renders the consent page (200) with a notice, a fresh jti, and the original ExpiresAt — replay→re-render cycles cannot extend a captured blob's life past the original 5-minute window, and a new explicit click is always required, so nothing auto-approves. mcp_auth_replay_detected_total{kind="consent"} increments once per replayed POST — note this now also counts benign double-submits, not only adversarial replays; do not page on low counts of this kind (see Metrics). The re-render loop is bounded by the per-IP /consent rate bucket. With no replay store wired (configured opt-out) the handler falls back to the prior stateless behavior (token unique, audience-bound, TTL-bounded, but replayable within the 5-min window).
Query params: code, state (from the IdP)
Behavior:
- If the IdP returns an
error(RFC 6749 §4.1.2.1), propagate it to the client - Decrypt the
state→ retrieve the session, verify not expired - Single-use claim on the session's
sidwhen a replay store is wired — a replayed/callbackis rejected with 400invalid_request+error_code=callback_state_replayBEFORE the upstream IdP exchange runs (no fan-out, no audit-log noise).mcp_auth_replay_detected_total{kind="callback_state"}increments. Emptysid(legacy session in flight during rollout) falls through to stateless behavior. Store-error path fail-closes to 503 +replay_store_unavailable+Retry-After: 5. - Exchange the code with the IdP (POST token endpoint, 10s timeout) to obtain
id_token+access_token - Validate the
id_tokenvia go-oidc (JWKS signature auto-discovery, issuer, audience) - Extract claims:
sub,email,email_verified,name - If
email_verifiedis present and false, reject with 403access_denied+error_code: email_not_verified(absent claim is accepted — not all IdPs emit it) - Extract groups from the configured claim (
GROUPS_CLAIM, defaultgroups) - If
ALLOWED_GROUPSis configured, verify the user belongs to at least one allowed group → 403access_denied+error_code: group_not_allowedotherwise (the metric label staysgroup) - Encrypt an internal authorization code with AES-GCM (60s TTL):
{
token_id (UUID, used for single-use replay check),
client_id (internal UUID),
redirect_uri, code_challenge,
subject, email, name, groups,
expires_at
}
- Redirect 302 to
redirect_uri?code={encrypted_code}&state={original_state}&iss={PROXY_BASE_URL}- Built via
url.Parse+ merged query params (safe even if redirect_uri already contains query params) issis emitted per RFC 9700 §2.1.4 (mix-up defense): a client that talks to multiple ASes can verify the response came from the AS it actually sent the request to. Value matches theissuerfield in the RFC 8414 metadata document.
- Built via
Request body (application/x-www-form-urlencoded, max 1 MB):
For grant_type=authorization_code:
grant_type=authorization_code
&code=<encrypted internal code>
&redirect_uri=<must match>
&client_id=<must match>
&code_verifier=<PKCE verifier, 43-128 chars per RFC 7636 §4.1>
For grant_type=refresh_token:
grant_type=refresh_token
&refresh_token=<encrypted token>
&client_id=<must match>
Behavior — authorization_code:
- Reject repeated singleton params (
resourcemay appear more than once per RFC 8707); everyresourcevalue must match an accepted resource URI; validatecode_verifiershape (43-128 unreserved characters, RFC 7636 §4.1) - Decrypt the code, verify not expired
- Decrypt the
client_id, verify not expired - Verify
client_id(internal UUID) andredirect_urimatch the code - Validate PKCE: base64url-encoded
SHA256(code_verifier)== storedcode_challenge(constant-time comparison) - If
REDIS_URLis configured, atomically claim the code'stoken_idviaSET NX. A second attempt is rejected withinvalid_grant+error_code: code_replay; Redis failures fail closed with 503 - Forwarding mode only: redeem the IdP refresh token at the IdP (see Upstream IdP token forwarding)
- Issue an opaque access token (AES-GCM, 1h TTL, at most 1h in forwarding mode) and a refresh token (AES-GCM, 7d TTL)
Behavior — refresh_token:
-
Decrypt the refresh token, verify its
audiencematchesPROXY_BASE_URL -
If
REVOKE_BEFOREis configured, reject if refreshiat< cutoff (bulk revocation applies to refresh tokens too, not only access tokens) -
Verify the refresh is not expired
-
Decrypt the
client_id, verify audience + not expired + UUID matches the refresh -
If
REDIS_URLis configured: a. Reject ifrefresh_family_revoked:<fam>is set (prior reuse killed the family) b. Atomically claimrefresh:<tid>viaSET NX, recording the claim's set time. If the key is already claimed AND the prior claim landed withinREFRESH_RACE_GRACE_SEC(default 2s, max 10s, set 0 to disable): treat as a benign concurrent submit — return 429invalid_grantwitherror_code: refresh_concurrent_submit+Retry-After: 2. The family is NOT revoked; the legitimate peer's rotation already succeeded, the racing client retries with the new refresh once it lands. If the prior claim is past the grace window: reuse detected — mark the family revoked for 7 days, reject witherror_code: refresh_reuse_detected. Any subsequent sibling refresh also gets rejected in step 5aNote on the 429 status: RFC 6749 §5.2 defines
/tokenerrors as 400 / 401. Returning 429 here is a deliberate deviation — most OAuth client libraries treat 429 +Retry-Afteras "back off and retry", which is exactly the desired behavior for a racing peer (the legit rotation already succeeded; the racing client should retry with the new refresh from shared storage). Theerror_code=refresh_concurrent_submitfield disambiguates from generic rate limiting for clients that look at the body. A 400invalid_grantwas considered but rejected: it would force every client library to add a custom retry path keyed offerror_codeinstead of using its existing 429 handling. -
Forwarding mode only: redeem the IdP refresh token at the IdP (see Upstream IdP token forwarding)
-
Issue new access + refresh tokens. The new refresh inherits the original
fam(so reuse detection spans the lineage) and gets a freshtid.iatis set tonowso it survives the nextREVOKE_BEFOREapplication
Response 200 JSON:
{
"access_token": "<opaque token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<opaque refresh token>"
}expires_in is 3600. In forwarding mode it is at most 3600: the access token expires at min(1h, IdP expiry − 60 s).
Headers Cache-Control: no-store and Pragma: no-cache required (RFC 6749 §5.1).
Standard OAuth2 errors:
{ "error": "invalid_grant", "error_description": "...", "error_code": "..." }error_code is an optional extension field for machine-readable internal
error identifiers. Clients must treat it as advisory and rely on the
standard error field for OAuth behavior.
Opaque format: JSON → AES-GCM encryption with TOKEN_SIGNING_SECRET → base64url.
Access token payload:
type Claims struct {
TokenID string // UUID
Audience string // PROXY_BASE_URL — bound at issuance, verified on every Validate
Subject string // IdP sub
Email string
Groups []string // from GROUPS_CLAIM in id_token
ClientID string
IssuedAt time.Time
ExpiresAt time.Time
IdPAccessToken string // forwarding mode only (omitempty); see below
}Refresh token payload (sealed identically; TokenID and FamilyID drive the optional Redis-backed reuse detection):
type sealedRefresh struct {
TokenID string // UUID, unique per refresh (single-use key when Redis is wired)
FamilyID string // UUID, constant across rotations — shared by every sibling in the lineage
Subject string
Email string
Groups []string
ClientID string
Audience string
IssuedAt time.Time // used by REVOKE_BEFORE bulk revocation
ExpiresAt time.Time
IdPRefreshToken string // forwarding mode only (omitempty); see below
}No JWT exposed — opaque token only on the MCP client side. Validated by AES-GCM decryption + audience check + expiry. No store required.
All MCP routes (/* except OAuth endpoints):
- Extract
Authorization: Bearer <token> - Decode and validate the opaque token (AES-GCM decryption, expiry check)
- Verify
claims.Audience == PROXY_BASE_URL— rejects tokens minted by a sibling instance sharing the same secret but with a different baseURL - If
REVOKE_BEFOREis configured, reject ifiat< cutoff (bulk revocation) - Inject into context:
sub,email,groups— and, in forwarding mode, the IdP access token; a token without one is refused withinvalid_token(minted before the mode was switched on) - On failure, return
401per RFC 6750 §3.1:- Missing or malformed
Authorizationheader →{ "error": "invalid_request", "error_description": "bearer credential is missing or malformed" } - Token decrypt/expiry/audience/iat failures →
{ "error": "invalid_token", "error_description": "bearer token is invalid, expired, or not intended for this resource" }Both responses includeWWW-Authenticate: Bearer error="<code>", error_description="<text>", resource_metadata="{PROXY_BASE_URL}/.well-known/oauth-protected-resource"(RFC 9728 §5.1 + RFC 6750 §3). Theerror_descriptionis a closed allowlist of fixed strings — no caller-controlled data reaches the header.
- Missing or malformed
After auth middleware passes:
// Forward to upstream MCP server
// Client request path forwarded verbatim; proxy mount == UPSTREAM_MCP_URL path
// Added headers:
r.Header.Set("X-User-Sub", claims.Subject)
r.Header.Set("X-User-Email", claims.Email)
r.Header.Set("X-User-Groups", "group1,group2") // comma-separated, omitted if empty
r.Header.Del("Authorization") // do not leak the internal token
// Forwarding mode only: the user's IdP access token, re-applied on
// every same-origin redirect hop. Otherwise UPSTREAM_AUTHORIZATION_HEADER
// when set, otherwise nothing.
r.Header.Set("Authorization", "Bearer "+idpAccessToken)
// Support SSE (text/event-stream): no response buffering
// Support Streamable HTTP (chunked): immediate flushUse httputil.ReverseProxy with FlushInterval: -1 (immediate flush) to support SSE and streaming. The underlying *http.Transport sets ResponseHeaderTimeout: 30s so a wedged upstream fails fast during header negotiation — stream bodies themselves remain uncapped. The transport follows 307/308 redirects server-side (Python FastAPI/Starlette backends), same-host only, body replayed, max 10 hops. On exhaustion the proxy responds 502 Bad Gateway with {"error":"bad_gateway","error_description":"too many upstream redirects"} rather than echoing the last 307/308 (which would leak a broken upstream Location: to the MCP client). Proxied request bodies are capped at 16 MiB via http.MaxBytesReader to bound the memory the redirect-follow buffer can hold.
Off by default. For upstream MCP servers that must call other APIs as the user — typically
through an on-behalf-of exchange at the IdP — knowing who the user is from
X-User-* is not enough; they need a token the IdP issued for them. With
UPSTREAM_FORWARD_IDP_TOKEN=true:
/authorizeasks the IdP foropenid email profileplusOIDC_EXTRA_SCOPES(e.g.api://<upstream-app>/access_as_user offline_accesson Entra ID). Clients still cannot request scopes:scopes_supportedstays[]and the client'sscopeis ignored./callbackverifies the id_token as usual and also keeps the IdP refresh token, sealed into the authorization code. The IdP access token is not kept: the code travels in a redirect URL and an access token with a groups claim can run to several KB. An IdP that returns no refresh token fails the sign-in withidp_refresh_token_missing./token(code grant), after PKCE and the single-use claim, redeems the IdP refresh token once at the IdP token endpoint with an explicitscope. The proxy access token carries the IdP access token and expires atmin(1h, IdP expiry − 60 s)(expires_inreports the real remaining lifetime; an IdP token that would leave the proxy token under a minute — one living under about two minutes — is refused); the proxy refresh token carries the latest IdP refresh token. When the IdP sends noexpires_in, a JWT access token's ownexpbounds the lifetime.- Each MCP request: the auth middleware hands the IdP access token to the reverse proxy,
which — after stripping the client's own token as always — sends it as
Authorization: Bearer.X-User-*are injected as before. /token(refresh grant), after the existing cutoff, family and reuse checks (so a revoked or replayed refresh token never reaches the IdP), refreshes at the IdP and seals the new IdP tokens into the new proxy tokens. A verified id_token returned on refresh re-applies the sign-in policy: samesub(elseid_token_verification_failed),email_verified, group-name validation andALLOWED_GROUPS, and updates email and groups. When the IdP returns no id_token, the previous email and groups are kept, never widened, andidp_refresh_id_token_missingis logged. An id_token that is present but fails verification, or whose claims cannot be parsed, is refused: 400invalid_grant/id_token_verification_failed, logidp_refresh_id_token_unverified. The refresh token stays spent and the user signs in again. Verification runs under its own 10 s timeout.
IdP answers at /token that are not a success map to three outcomes. Rejected (IdP error
codes invalid_grant, interaction_required, login_required, consent_required,
invalid_scope, on a 4xx other than 429): 400 invalid_grant / idp_refresh_rejected, the claim
stays spent and the client signs in again. Unavailable: 503 temporarily_unavailable /
idp_refresh_unavailable + Retry-After. This covers a transport error, a timeout, any 5xx or 429 whatever its body, the IdP error codes temporarily_unavailable or server_error on a 4xx, the proxy's own client credentials refused (invalid_client, unauthorized_client, 401), a 4xx without an error member, any 3xx (redirects are never followed), or a 2xx whose body is not a JSON object (a maintenance page, a WAF block). A local throttle
(IDP_EXCHANGE_*) answers 503 idp_exchange_throttled the same way. In both cases the proxy
releases the single-use claim, so the client retries with the same code or refresh token. A
failed release answers invalid_grant instead, since the retry would read as a replay.
Failed: 400 invalid_grant / idp_refresh_failed, the claim stays spent. This covers
a JSON error with an unknown code, a 2xx JSON object that is malformed or has no usable Bearer access token, a 2xx whose body broke off mid-read, and an expires_in that is stated and zero or negative. Retry-After is the proxy's jittered 2-4 s. On a 5xx, a 429 or a temporarily_unavailable / server_error answer, an IdP Retry-After in seconds above 4 replaces it, capped at 120 s (the HTTP-date form is ignored). Otherwise it is 60 s on a 3xx, and when a 4xx other than 429 refused the proxy's client credentials or carried no error member. Logs for these failures carry the IdP's error code, the HTTP status and the
proxy's own reason (transport error, unsupported token_type). They never carry a token or the
IdP's error_description. Any failure to mint tokens after the IdP call also answers invalid_grant, never a 5xx: the
client's token is spent, and a retry would only trip reuse detection. The IdP refresh is a hand-written
RFC 6749 §6 call — x/oauth2 cannot add a scope to a refresh, and without one some IdPs (Entra ID)
choose the new token's audience themselves. Client authentication follows the endpoint's
AuthStyle; auto-detection tries Basic, falls back to form parameters on a non-grant 4xx other than 429, and
remembers the style once the IdP accepts the credentials. Redirects are never followed. At most
64 refresh calls run at once. A call over that bound is answered idp_refresh_unavailable without
reaching the IdP.
What does not change: the stateless design (the IdP tokens are optional fields inside the existing
sealed payloads; Redis still holds replay markers only), AEAD purpose and audience binding, RFC
8707 resource binding, PKCE, DCR, the consent page, refresh rotation with reuse detection and
REVOKE_BEFORE. The proxy always strips the token the client presents and never forwards it.
The upstream receives a different token, one the IdP issued to the proxy's own client for the
scopes in OIDC_EXTRA_SCOPES. Whether that avoids the "token passthrough" anti-pattern of the MCP
security best practices depends on the operator. The token carries the upstream's audience only
when OIDC_EXTRA_SCOPES names the upstream's scope, or an audience mapper at the IdP adds it. The
proxy does not check aud. With forwarding on and OIDC_EXTRA_SCOPES empty, the token has the
IdP's default audience (a Microsoft Graph token on Entra ID), and the upstream could replay it
there. Set the upstream scope. The upstream must validate the token itself (issuer, audience,
signature, expiry).
Trade-offs, also in docs/threat-model.md row 17: whoever holds
TOKEN_SIGNING_SECRET can now open the IdP refresh tokens inside client-held tokens; refresh
needs the IdP to be reachable (docs/runbooks/idp-outage.md);
codes, access and refresh tokens grow by the IdP token sizes, so their open() cap is raised to
the 64 KB header block in this mode (only for those three purposes), the IdP access token's length
comes off the groups budget, and a code, access or refresh token that would not pass its own cap is
never minted. In
return, revocation gets stronger: every refresh asks the IdP, so a password reset or a disabled
account bites within one access-token lifetime. A group removal does too when the IdP returns an
id_token on refresh. When it returns none, email and groups stay as last verified (log
idp_refresh_id_token_missing). A verified refresh id_token
without the groups claim reads as "no groups", exactly as at sign-in (fail closed), and is logged
as idp_refresh_groups_claim_missing.
IdP prerequisites. Entra ID: expose a scope on the upstream's app registration, grant it to the
proxy's app registration, and set OIDC_EXTRA_SCOPES="api://<upstream-app>/<scope> offline_access".
Keycloak: add an audience mapper for the upstream on the proxy's client. The user needs the
offline_access realm role when that scope is requested. The default 5-minute access tokens give
4-minute proxy tokens, so clients refresh every 4 minutes.
Known limitation. After an ambiguous IdP outcome (the 10 s timeout, or the client hanging up once the request left) the proxy releases the single-use claim and the client retries with the same token. An IdP that rotates refresh tokens and revokes on reuse (Keycloak with "Revoke Refresh Token", Okta, Auth0) then rejects the retry, and the user signs in again. Entra ID does not revoke on reuse and is not affected.
The IdP call (up to 10 s) also runs while the client's single-use claim is held.
A client that resends the same authorization code while the first request is still waiting on the
IdP, or the same refresh token more than REFRESH_RACE_GRACE_SEC after the first request, is read
as a replay: the family is
revoked and authorization_code_replay / refresh_token_reuse_detected fires. It fails closed (no
extra tokens; the user signs in once more) but raises a false alert. Marking claims "in flight" in
the replay store would remove it; left out of this change to keep the replay semantics untouched.
UPSTREAM_MCP_URL must include a path component; that path is the proxy's public mount and the path forwarded upstream, verbatim both sides. No join, no strip, no rewrite. If the client hits {PROXY_BASE_URL}<path>, the upstream sees <path>.
Real-world MCP endpoints use many paths — set UPSTREAM_MCP_URL accordingly:
| Product | Path | Example value |
|---|---|---|
| FastMCP (Python) default | /mcp |
http://fastmcp:8000/mcp |
GitHub Copilot (api.githubcopilot.com) |
/mcp |
https://api.githubcopilot.com/mcp |
Cloudflare (mcp.cloudflare.com) |
/mcp |
https://mcp.cloudflare.com/mcp |
| Atlassian Rovo | /v1/mcp |
https://rovo.atlassian.com/v1/mcp |
| GitLab | /api/v4/mcp |
https://gitlab.com/api/v4/mcp |
Origin-only URLs (no path / lone /), query, fragment, userinfo, and paths that collide with a reserved control-plane route (/healthz, /register, /authorize, /callback, /token, /.well-known) are rejected at startup.
The router is built in main.go (func main) — see that file rather than a copy here, since this block historically rotted. High level:
- Global middlewares: in-flight WaitGroup → strip inbound
X-Request-Id→chimw.RequestID→zapMiddleware→chimw.Recoverer→ per-IP rate limiter. - OAuth endpoints (
/register,/authorize,/consent,/callback,/token) and the discovery surface (/.well-known/oauth-authorization-server,/.well-known/oauth-protected-resource, mount-suffixed variants, and the openid-configuration / under-mount 404 carve-outs) carry per-endpoint rate limiters whenRATE_LIMIT_ENABLED=true(passthrough otherwise). Discovery is silent on rate-limit per RFC 8414 §3 / RFC 9728 §3.1; the 60/min/IP ceiling here only catches floods. The 429 carriesRetry-After: <window seconds>, which states when to retry without disclosing the per-window quota; theX-RateLimit-Limit/-Remaining/-Resetheaders httprate sets internally are stripped from every response — production MCP servers (Cloudflare, GitHub Copilot, Atlassian, Notion, Sentry) all keep them silent, and the IETF rate-limit-headers draft warns that disclosing quota state on auth/error paths leaks operational capacity to attackers.replayStoreis wired whenREDIS_URLis set, and as an in-memory store forUPSTREAM_FORWARD_IDP_TOKEN=truewithout it. - 405 responder (
installMethodNotAllowed, wired after every route so it can read the finished routing table): replaces chi's empty-bodied default with the shared error sink, keeps the RFC 9110 §15.5.6Allowheader, and applies theBrowserFacingmarker per path — a wrong method on/consentrenders the page, one on/tokenor/registerstaysapplication/jsonlike every other error there. - Liveness
/healthz(always 200) on the public listener; readiness/readyzlives ONLY on the metrics listener (an unauthenticated/readyzon the public port is a Redis-DoS amplifier — see comment atmain.go:304). - MCP proxy mounts at
cfg.UpstreamMCPMountPath(path fromUPSTREAM_MCP_URL) underauthMW.Validate→RPCPeek→ per-subject concurrency limiter. Client path == upstream path, verbatim, no rewrite.
Always return errors conforming to RFC 6749. This is the wire form for every programmatic caller;
a browser that asks for text/html gets the same error as a page instead (see Browser-facing
rendering below):
type OAuthError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}Standard error values (RFC 6749 §5.2): invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope, server_error, access_denied, temporarily_unavailable.
Extension error_code values (proxy-specific, advisory — clients MUST rely on error for OAuth behavior):
error_code |
When | Paired error |
|---|---|---|
code_replay |
Authorization code reused (requires Redis) | invalid_grant |
refresh_reuse_detected |
Refresh token replayed after rotation → family revoked (requires Redis) | invalid_grant |
refresh_family_revoked |
Refresh token whose family was previously revoked | invalid_grant |
email_not_verified |
id_token email_verified is false (metric label: email_unverified); also re-checked on a forwarding-mode refresh |
access_denied (invalid_grant at /token) |
subject_missing |
IdP returned a verified id_token without a sub claim (L5) |
access_denied |
group_invalid |
IdP group name contains , \r \n \x00; also re-checked on a forwarding-mode refresh |
access_denied (invalid_grant at /token) |
group_not_allowed |
User belongs to none of ALLOWED_GROUPS (metric label: group); also re-checked on a forwarding-mode refresh when the IdP returns an id_token, so a removal then bites within one access-token lifetime |
access_denied (invalid_grant at /token) |
session_expired / session_audience_mismatch / callback_params_missing |
/callback flow state is unusable; the page tells the user to start again |
invalid_request |
session_unknown |
/callback state cannot be opened. Paired error is invalid_request, or the allowlisted IdP error when the IdP itself returned one |
invalid_request (or the IdP's) |
consent_token_missing / consent_token_invalid / consent_token_expired / consent_token_audience_mismatch |
/consent flow state is unusable; same "start again" advice |
invalid_request |
idp_exchange_failed / id_token_missing / id_token_claims_unparsable |
Upstream IdP failed the exchange or returned an unusable id_token | server_error |
callback_state_replay |
/callback replayed (requires Redis); metric: mcp_auth_replay_detected_total{kind="callback_state"} — not an access_denied_total reason |
invalid_request |
idp_exchange_throttled |
Outbound IdP exchange throttled (IDP_EXCHANGE_RATE_PER_SEC) — at /callback, and at /token in forwarding mode, where the code or refresh token stays usable for the retry (invalid_grant instead if it could not be given back) |
temporarily_unavailable (or invalid_grant at /token) |
refresh_concurrent_submit |
Racing refresh inside REFRESH_RACE_GRACE_SEC |
invalid_grant |
redirect_uri_missing / redirect_uri_mismatch |
redirect_uri absent or not registered (client input, /authorize) |
invalid_request |
redirect_uri_malformed |
An already-validated redirect_uri failed to re-parse at /authorize, /consent or /callback, leaving nowhere to deliver the §4.1.2.1 envelope — invariant violation, see the browser-facing rendering note |
the original envelope's error (server_error, access_denied, unsupported_response_type, invalid_target, invalid_request, or the allowlisted IdP one) |
consent_query_params_forbidden / consent_auth_header_present / consent_body_too_large / consent_form_malformed / consent_action_invalid |
POST /consent request-shape rejections |
invalid_request |
parameter_repeated |
A singleton query/form parameter was sent more than once (/authorize, /consent, /callback, /token) |
invalid_request |
method_not_allowed |
Wrong HTTP method for the route (e.g. GET /consent from a bookmark) |
invalid_request |
not_found |
No route at that path — a mistyped or trailing-slash bookmark, a stray probe, or a discovery carve-out. One body for all of them; only the browser-facing paths additionally render it as a page | invalid_request |
refresh_revoked_iat_cutoff |
Refresh token predates REVOKE_BEFORE (bulk cutoff). Emits no metric — mcp_auth_access_denied_total{reason="token_revoked_iat_cutoff"} counts the separate access-token rejection in middleware/auth.go, which carries no wire code. Distinct from refresh_family_revoked, which is reuse detection |
invalid_grant |
code_seal_failed / interstitial_render_failed |
Internal failure minting a code or rendering the interstitial | server_error |
client_id_missing |
client_id absent (/authorize only — /token folds it into an unmetered generic invalid_request) |
invalid_request |
client_id_unknown |
client_id did not decrypt, or decrypted with the wrong purpose tag. One code for both on purpose: distinct codes would tell an unauthenticated caller which of the two happened, i.e. whether a blob they hold is a genuine token of this proxy. The metric labels stay distinct (client_id_invalid, client_typ_mismatch) |
invalid_client / invalid_grant |
client_audience_mismatch / client_registration_expired |
Client registered for another PROXY_BASE_URL, or past its TTL; each matches the metric label of the same name |
invalid_client |
replay_store_unavailable |
Redis unreachable; handler fails closed | server_error |
id_token_verification_failed |
go-oidc rejected the IdP id_token; at /token in forwarding mode, the id_token returned on refresh fails verification, has unparsable claims, or names a different sub (invalid_grant) |
server_error (or invalid_grant) |
token_issue_failed |
AES-GCM seal error when minting an access token. In forwarding mode also an IdP access token that would leave the proxy token under a minute (i.e. lives under about two minutes), or IdP tokens that would push an access or refresh token over its open() cap; there the code or refresh token is already spent, so the answer is invalid_grant — a 5xx would invite a retry that reads as a replay |
server_error (invalid_grant in forwarding mode) |
idp_refresh_token_missing |
/callback in forwarding mode (UPSTREAM_FORWARD_IDP_TOKEN): the IdP code exchange returned no refresh token — offline access not granted to the proxy's client |
server_error |
idp_token_missing |
/token in forwarding mode: the code or refresh token was minted before the mode was switched on and carries no IdP token; the client signs in again. Metric: mcp_auth_access_denied_total{reason="idp_token_missing"} |
invalid_grant |
idp_refresh_rejected |
/token in forwarding mode: the IdP refused the refresh (invalid_grant, interaction_required, login_required, consent_required, invalid_scope); the client signs in again. Metric: mcp_auth_idp_refresh_total{result="rejected"} |
invalid_grant |
idp_refresh_unavailable |
/token in forwarding mode: A transport error, a timeout, any 5xx or 429 whatever its body, the IdP error codes temporarily_unavailable or server_error on a 4xx, the proxy's own client credentials refused (invalid_client, unauthorized_client, 401), a 4xx without an error member, any 3xx (redirects are never followed), or a 2xx whose body is not a JSON object (a maintenance page, a WAF block), or more than 64 refresh calls in flight. The code or refresh token is NOT consumed — retry after Retry-After. Retry-After is the proxy's jittered 2-4 s. On a 5xx, a 429 or a temporarily_unavailable / server_error answer, an IdP Retry-After in seconds above 4 replaces it, capped at 120 s (the HTTP-date form is ignored). Otherwise it is 60 s on a 3xx, and when a 4xx other than 429 refused the proxy's client credentials or carried no error member. If giving it back fails (replay store down at the same time), the answer is invalid_grant without Retry-After: a retry would read as a replay. Metric: mcp_auth_idp_refresh_total{result="unavailable"} |
temporarily_unavailable (or invalid_grant) |
idp_refresh_failed |
/token in forwarding mode: a failure waiting will not clear — a JSON error with an unknown code (invalid_request, unsupported_grant_type, …), a JSON 2xx without a usable Bearer access token, a 2xx whose body broke off mid-read, or an expires_in that is stated and zero or negative. The code or refresh token stays spent; the client signs in again. Metric: mcp_auth_idp_refresh_total{result="failed"} |
invalid_grant |
Browser-facing rendering. /authorize, /consent and /callback terminate in the user's
browser, not in the MCP client — a raw JSON body there is a dead end for the human reading it. Those
three routes are wrapped in handlers.BrowserFacing, and the shared error sink renders an HTML page
when both hold: the route carries that marker, and the request's Accept lists text/html
explicitly. Everything else gets the RFC 6749 JSON body unchanged.
Gating on the route, not on Accept alone, is what keeps /token and /register on
application/json as RFC 6749 §5.2 and RFC 7591 §3.2.2 require, whatever a caller asks for;
discovery is likewise machine-only. */* never triggers the page, and a text/html sitting inside a
quoted parameter does not either.
The page carries a title derived from the status, the error value and the error_code: a
transient code reads as "Temporarily unavailable" even on a 5xx, and otherwise a server_error never
reads as an authorization decision whatever status it rides on. It carries the error_description as its reason
sentence, one line of
advice, and error + error_code in small print for support. The advice is chosen from the failure:
wait and retry on 429/503 and on the transient error_codes (idp_exchange_throttled,
replay_store_unavailable — 502 alone is not enough, since two permanent IdP misconfigurations ride
it); wait, then go back to the application and try again on idp_exchange_failed, whose callback
state was claimed before the exchange, so reloading the same URL can only yield
callback_state_replay; go back to the application and start again on the error_codes whose flow
state is dead (session_*, consent_token_*, callback_*) and on the /consent request-shape
rejections a fresh flow re-forms (consent_query_params_forbidden, consent_form_malformed,
consent_action_invalid, parameter_repeated, consent_auth_header_present,
consent_body_too_large, method_not_allowed, not_found); reconnect this service in your application on the stale-registration codes
(client_registration_expired, client_id_unknown, client_audience_mismatch,
redirect_uri_mismatch), which a client-side re-registration clears (see the runbook);
verify your email address with your identity provider on email_not_verified, the one denial the
user clears without an operator; and contact the administrator quoting the code otherwise. The
mapping is codeHints in handlers/error_page.go, and
TestBrowserReachableCodes_HaveDeliberateAdvice fails if a browser-reachable code has neither a row
there nor an explicit record that the fallback is intended — telling a throttled user to call an
administrator is worse than saying nothing. The per-endpoint rate limiter serves the pre-rendered
page only to a request that also carries Sec-Fetch-Dest: document (a real top-level navigation) and
answers plain JSON otherwise — the 429 path is unbounded and its page is ~16x its own 78-byte JSON body, so a
spoofed Accept alone must not buy the amplification; its responses carry
Vary: Accept, Sec-Fetch-Dest.
Scope is the errors the proxy renders itself. An error that §4.1.2.1 delivers to an already-validated
redirect_uri — the IdP-error path on /callback, every /authorize failure past redirect_uri
validation, deny and server-error on /consent — leaves as a redirect or through the consent
interstitial, not as a page. The one exception is the invariant-violation fallback: if that
already-validated redirect_uri fails to re-parse there is nowhere to send the envelope, so the error
goes back through the sink and renders like any other.
The page is script-free under its own locked CSP (errorPageCSP, handlers/pages.go — default-src 'none'; style-src 'sha256-…'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'; the style-src names the
sha256 of the page's compile-time <style> content instead of 'unsafe-inline', computed from the
same constant the template embeds so the two cannot drift; all three proxy-rendered pages are built
this way). Both representations carry Cache-Control: no-store, and Vary: Accept is set on the
browser-facing routes only — /token and /register have a single representation by RFC mandate, so
advertising negotiation on them would invite a cache to key on an Accept that changes nothing. A
WWW-Authenticate challenge is dropped whenever a page ships, where it would only make the browser pop
a credential dialog and discard the body. The body is rendered into a buffer before any header is
committed, so a template failure falls back to the JSON body instead of shipping a blank 4xx (and
increments mcp_auth_page_render_failed_total{page="error"}). The HTTP status is identical either way, so
nothing machine-observable changes.
IdP-supplied error values on /callback are allowlisted against the
RFC 6749 §4.1.2.1 set (invalid_request, invalid_client,
unauthorized_client, access_denied, unsupported_response_type,
invalid_scope, server_error, temporarily_unavailable); anything
outside that set is rewritten to server_error before being echoed to
the MCP client. The IdP-supplied error_description is not echoed
at all: it is discarded and replaced by a fixed literal on both the
redirect and the proxy-rendered branch, because it is attacker-supplied
on a /callback hit and would otherwise be rendered under the proxy's
own domain. The 200-byte / printable-ASCII clamp at the sink remains as
defence in depth for any future caller.
See Dockerfile. Static Go binary on gcr.io/distroless/static-debian13:nonroot (UID 65532, no shell, no apt). Build args inject build-time metadata via -ldflags -X; OCI labels carry source/created/version/revision.
Operator-load-bearing invariants that are not surfaced by the env table or the endpoint reference. The "Configuration" table is the canonical knob list; the "Architecture" and "Endpoints" sections are the canonical flow descriptions.
- redirect_uri shape: exact match (with the RFC 8252 §7.3 loopback-port relaxation at
/authorize);http://allowed only to a loopback host (full 127/8 range,::1,::ffff:127.0.0.1,::0.0.0.1,localhost,localhost.); non-http(s) schemes rejected even on loopback; fragments and userinfo rejected; length capped at 512 chars; at most 5 entries per client registration. The port relaxation applies only to the registered ↔/authorizematch — at/tokenthe client MUST echo the sameredirect_urivalue it sent to/authorize(RFC 6749 §4.1.3 byte-equality), which native apps already do because they bind their loopback port once and reuse it for the whole flow. - Structured logs: zap JSON,
request_idon every line. InboundX-Request-Idis stripped before chi mints one (defeats client-controlled log forgery). Authenticated requests carrysubandemail; JSON-RPC requests additionally carryrpc_method,rpc_tool(capped at 128 chars),rpc_id(capped at 64), all passed through a narrow allowlist (ASCII alphanumerics plus._:/-+).MCP_LOG_BODY_MAX=0suppresses therpc_*fields;ACCESS_LOG_SKIP_REdrops whole lines for matching paths. - Business metrics (under
/metricsonMETRICS_ADDR):mcp_auth_tokens_issued_total{grant_type}mcp_auth_access_denied_total{reason}— seedocs/configuration.mdfor the enumerated reasonsmcp_auth_replay_detected_total{kind}(code/refresh/consent/callback_state) —code,refreshandcallback_stateare rejected requests (security signal);consentcounts replayed consent POSTs that were answered with a re-rendered consent page, which includes benign double-submits / back-button re-POSTs — alert on sustained rate, not single ticksmcp_auth_authorize_initiated_total{flow}—/authorizerequests admitted past validation, by flow (consent/redirect)mcp_auth_idp_exchange_throttled_total— outbound proxy → IdP exchanges refused byIDP_EXCHANGE_RATE_PER_SEC, at/callbackand at/tokenin forwarding mode. A throttled/tokencall also incrementsmcp_auth_idp_refresh_total{result="throttled"}mcp_auth_rate_limited_total{endpoint}mcp_auth_clients_registered_totalmcp_auth_groups_claim_shape_mismatch_total— IdP-schema-drift signal; user is admitted with empty groups, so it's NOT a denialmcp_auth_page_render_failed_total{page}— proxy-rendered page template execute failures, by page (error/consent/interstitial). The error page falls back to the JSON body; the consent page and interstitial fall back to the client'sredirect_urienvelope. Zero in a healthy deploy; any increment is a template regressionmcp_auth_groups_claim_truncated_total— access tokens minted with a truncatedgroupsclaim (GROUPS_CLAIM_MAX_BYTES, default 32 KB). Authorization-visible: the claim is forwarded upstream asX-User-Groups, so a truncated user may be refused by the MCP server despite passingALLOWED_GROUPSheremcp_auth_token_seals_total{purpose}— cross-replica AES-GCM seal counter; alert onsum(increase(metric[7d])) > 2**28to driveTOKEN_SIGNING_SECRETrotationmcp_auth_rpc_calls_total{tool}/mcp_auth_rpc_calls_failed_total{tool}/mcp_auth_rpc_request_bytes_total{tool}/mcp_auth_rpc_response_bytes_total{tool}— per-tool RPC traffic. Fire only on JSON-RPCtools/call(protocol-level methods likeinitialize/tools/listare excluded so_unknownreliably flags malformedtools/callpayloads). Batches fan out into onerpc_calls_totalincrement pertools/callentry; byte counters stay scoped to single-call requests because per-call Content-Length / response bytes cannot be honestly attributed inside a batch. Disabled by default (cardinality + privacy trade); opt-in viaMCP_TOOL_METRICS=true. Distinct labels capped byMCP_TOOL_METRICS_MAX_CARDINALITY(default 256) — overflow folds into_overflow, unparseable tool names into_unknownmcp_auth_rpc_batches_total/mcp_auth_rpc_batches_failed_total/mcp_auth_rpc_batch_bytes_total{direction}— batch-shape counters, disjoint from the per-tool family. One increment per HTTP request that decoded as a JSON-RPC batch with at least onetools/callentry; carries the request's actual Content-Length / BytesWritten. No per-tool label — batch contents do not have honest per-call attribution. Same opt-in toggle (MCP_TOOL_METRICS=true) as the per-tool family
- HTTP timeouts:
ReadTimeout: 30s,WriteTimeout: 0(SSE),IdleTimeout: 120s,ReadHeaderTimeout: 10s. - Header size limit:
MaxHeaderBytes: 64 KB(down from net/http's 1 MB default) on both listeners, so every header-driven cost — including the pre-authAcceptnegotiation scan, which the rate limiter by construction does not cover — is bounded. - SSE streams MUST flush; do not buffer
text/event-streamresponses. - Body size limit: POST endpoints capped at 1 MB via
MaxBytesReader. - 307/308 redirect following: proxy follows server-side for Python MCP backends that redirect
/mcp→/mcp/. Same-host only, body replayed, max 10 hops, scheme downgrade rejected. - PRM
resourcefield: the root/.well-known/oauth-protected-resourcecarries the trailing-slash form for Claude.ai canonicalization (intentional deviation from RFC 9728 §3 — see the Endpoints section). The per-mount variant is spec-strict. - Email verification:
email_verified=falsein the id_token is rejected at/callbackwith 403access_denied+error_code: email_not_verified. A missing claim is accepted — not every IdP emits it. - Security headers: every response on the public listener carries
Strict-Transport-Security: max-age=63072000; includeSubDomains(RFC 6797),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer(RFC 9700 §4.2.4 RECOMMENDED for OAuth ASes — defends againstcodeleakage via Referer), andContent-Security-Policy: default-src 'none'; frame-ancestors 'none'. Headers are set before the handler runs so they apply to every status code, including upstream pass-through 5xx and rate-limiter 429s. Not applied to the metrics listener (Prometheus scrape is in-cluster only). The HSTSincludeSubDomainsdirective assumes the operator's parent zone is all-HTTPS.
The stateless design supports horizontal scaling without sticky sessions. Required configuration symmetry across replicas:
TOKEN_SIGNING_SECRET— must be byte-identical (mount from aSecret, not generated per-pod). The single most important invariant: a mismatch breaks every cross-pod token validation.PROXY_BASE_URL— must be the public DNS name reached by clients, not a per-pod hostname. Audience binding enforces this — a pod with a wrongPROXY_BASE_URLwill reject every token minted by its siblings.OIDC_*— same registration on the same IdP.UPSTREAM_MCP_URL— same in-cluster service URL.PKCE_REQUIRED,ALLOWED_GROUPS,GROUPS_CLAIM,REVOKE_BEFORE,REDIS_URL,REDIS_KEY_PREFIX,RATE_LIMIT_ENABLED— any asymmetry produces "works on some pods, not others" bugs.REDIS_URL+REDIS_KEY_PREFIXin particular must match across all replicas, otherwise single-use and reuse-detection guarantees only hold within each pod's local set of clients.
Recommended Deployment shape:
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 3
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 120 # match SHUTDOWN_TIMEOUT
containers:
- name: mcp-auth-proxy
envFrom:
- secretRef:
name: mcp-auth-proxy-secret
- configMapRef:
name: mcp-auth-proxy-config
livenessProbe:
httpGet:
path: /healthz
port: http
# /readyz lives ONLY on the metrics port — an unauthenticated
# readiness endpoint on the public listener is a Redis-DoS
# amplifier (a sustained probe flood saturates the pool, flips
# readiness fleet-wide, and drops every pod from the Service
# simultaneously). Probe via the metrics port the kubelet can
# reach in-cluster.
readinessProbe:
httpGet:
path: /readyz
port: metrics
ports:
- { name: http, containerPort: 8080 }
- { name: metrics, containerPort: 9090 }
---
apiVersion: v1
kind: Service
spec:
sessionAffinity: None # explicit; no stickiness needed
selector: { app: mcp-auth-proxy }
ports:
- { name: http, port: 80, targetPort: 8080 }Ship a PodDisruptionBudget with minAvailable: 1 (or 2 for ≥3 replicas) so node drains do not take out the whole auth plane at once.
The manifests/ folder ships a turn-key demo: a Docker Compose stack (Keycloak + Redis + fake MCP upstream + proxy) for local exploration and a Kubernetes reference set (Deployment, Service, Ingress, PodDisruptionBudget, plus scripts/generate-signing-secret.sh).
REVOKE_BEFORE is read at startup. Updating it requires a rolling restart, and during the rollout window some pods enforce the new cutoff while others still use the old one. Wait for kubectl rollout status to converge before assuming the cutoff is fleet-wide enforced.
config.Load() fails closed on the following config mistakes (fatal at startup):
TOKEN_SIGNING_SECRETshorter than 32 bytes.TOKEN_SIGNING_SECRETmatching an obvious-weakness pattern whenPROD_MODE=true. Three rejection classes: (1) all-same byte (aaaa…); (2) short repeating period (abcabc…,0123456789abcdef0123456789abcdef); (3) tiny alphabet — fewer than 8 distinct byte values, catching uneven-run-length shapes (aaaa…b) that defeat the period and all-same checks. Same gate applies to every entry inTOKEN_SIGNING_SECRETS_PREVIOUSso a rolling rotation cannot regress. UnderPROD_MODE=falsethe same secret triggers a non-fatal weakness warning instead. Real random output of any encoding (raw, hex, base64) is non-periodic AND has well over 8 distinct values in 32+ bytes, so it passes; canonical generator ismanifests/scripts/generate-signing-secret.sh.SHUTDOWN_TIMEOUTnon-positive or greater than 15 minutes (L2).REDIS_KEY_PREFIXcontaining{,},\r,\n, or any byte outside the 0x20..0x7E ASCII-printable range (L3).PROXY_BASE_URLwith a scheme other thanhttps://(orhttp://to a loopback host), a non-empty userinfo, a fragment, or a path beyond/(L8).MCP_LOG_BODY_MAX/MCP_PER_SUBJECT_CONCURRENCYnot parseable as non-negative integers.SHUTDOWN_TIMEOUT/REVOKE_BEFOREunparseable as duration / RFC3339.ACCESS_LOG_SKIP_REnot compilable as a Go RE2 regexp.UPSTREAM_FORWARD_IDP_TOKEN=truewithoutREDIS_URLunderPROD_MODE, or together withUPSTREAM_AUTHORIZATION_HEADER. Without a replay store, a stolen refresh token would mint a forwardable IdP token on every replay. OutsidePROD_MODE, withREDIS_REQUIRED=false, the proxy falls back to an in-memory replay store (single instance only, logreplay_store_in_memory).
Non-fatal startup warnings:
token_signing_secret_weakfires when the secret matches an obvious-weakness pattern (all-same byte, repeating period < length, or fewer than 8 distinct byte values) — signals a human-typed / patterned secret whose effective entropy is well below its length.token_seal_rotation_thresholdfires once after 2^28 successful seals per Manager, suggestingTOKEN_SIGNING_SECRETrotation before AES-GCM nonce-collision bounds matter (L6).