mcp-auth-proxy
Health Warn
- License — License: Apache-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 8 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
OAuth 2.1 + OIDC bridge for private MCP servers. Stateless, replay-safe, audit-defensible. Bring any IdP — Keycloak to Entra.
mcp-auth-proxy
OAuth 2.1 authorization server that fronts any OIDC IdP, so MCP clients
can speak to your private MCP server without you writing a single line
of auth code.
┌──────────────────┐ ┌────────────────────┐ ┌─────────────────┐
│ Claude / Cursor │ ───► │ mcp-auth-proxy │ ───► │ private MCP │
│ Claude Code │ │ OAuth 2.1 AS │ │ server │
│ MCP Inspector │ ◄─── │ (this project) │ ◄─── │ (unchanged) │
└──────────────────┘ └────────┬───────────┘ └─────────────────┘
│
▼
┌────────────────────┐
│ your OIDC IdP │
│ Keycloak · Entra │
│ Auth0 · Okta · … │
└────────────────────┘
TL;DR — deploy
docker run --rm -p 8080:8080 \
-e OIDC_ISSUER_URL=https://idp.example.com \
-e OIDC_CLIENT_ID=mcp-auth-proxy \
-e OIDC_CLIENT_SECRET='<your-idp-secret>' \
-e PROXY_BASE_URL=https://mcp.example.com \
-e UPSTREAM_MCP_URL=http://mcp-backend:8000/mcp \
-e TOKEN_SIGNING_SECRET="$(openssl rand -hex 32)" \
-e REDIS_URL=redis://your-redis-host:6379/0 \
ghcr.io/babs/mcp-auth-proxy:latest
Substitute the angle-bracketed placeholder with your real IdP
credential and pick a Redis URL that's reachable from the container
(host networking, an explicit --network, or the demo stack below
all work). Point your MCP client at https://mcp.example.com/mcp
and the proxy walks RFC 7591 → 8414 → 6749 → 8707 → OIDC → your
protected backend on its own.
For a full local stack with Keycloak + Redis + a sample MCP server
already wired up, see Demo stack.
Requirements
- OIDC IdP with discovery (
/.well-known/openid-configuration)
reachable from the proxy. Tested with Keycloak, Microsoft Entra ID;
any OIDC-compliant IdP works (Auth0, Okta, Google, …). - Redis ≥ 7 (or compatible) for production. Required by default
(REDIS_REQUIRED=true) so single-use authorization codes and
refresh-rotation reuse detection work across replicas. Seedocs/redis-production.mdfor sizing. - Public HTTPS terminating at an ingress that reaches the proxy's
LISTEN_ADDR(:8080by default). The IdP and the MCP clients both
seePROXY_BASE_URLover the public network. - Go 1.26+ if building from source. Container images are static
(CGO_ENABLED=0). - Kubernetes: any conformant cluster. Sample manifests under
manifests/. Production overlay enforces the safe
posture; see Deploying.
What it does
- Speaks OAuth 2.1 + PKCE to MCP clients (claude.ai, Claude Code,
Cursor, MCP Inspector, ChatGPT…). - Federates authentication to any OIDC-compliant IdP via
auto-discovery (no vendor lock-in, zero IdP-specific code). - Reverse-proxies to your unmodified upstream MCP server.
- Stateless design — every transient state (registrations, codes,
tokens) is AEAD-sealed into opaque strings; scale horizontally by
sharing one secret. - Redis-backed replay defense — single-use authorization codes,
refresh-rotation reuse detection (OAuth 2.1 §6.1), single-use
consent and callback-state tokens. - Per-IP rate limiting on every pre-auth endpoint, per-subject
concurrency caps on the authenticated route,email_verified
enforcement on the IdP id_token, Prometheus metrics for every
security-relevant event, and a proxy-rendered consent page on
by default.
The MCP spec requires an OAuth 2.1 Authorization Server in front of
protected MCP servers. You probably do not want to implement RFC 8414
/ 7591 / 9728 / 7636 / 8707 yourself, glue a session store in front of
every replica, or rewrite your MCP backend to understand OIDC. Drop
this in front, point it at your existing IdP, done.
Standards conformance
| RFC / Spec | Implements |
|---|---|
| OAuth 2.1 draft-13 | Authorization code + PKCE, hardened defaults |
| RFC 8414 | /.well-known/oauth-authorization-server |
| RFC 9728 | /.well-known/oauth-protected-resource + WWW-Authenticate |
| RFC 7591 | Dynamic Client Registration on POST /register |
| RFC 7636 | PKCE S256, 43-128 char verifier |
| RFC 8707 | resource indicator on /authorize and /token |
| MCP Authorization 2025-06-18 | End-to-end MCP auth flow |
Companion docs:
specs.md— design + flow rationale.docs/conformance.md— claim matrix +
compatibility notes + IdP evidence.docs/threat-model.md— STRIDE coverage
with code + test + runbook links.docs/configuration.md— full env-var
reference with rationale per knob.
Configuration
All configuration via environment variables. The five required
vars are below; everything else is optional and defaults to the safe
production posture.
| Variable | Description |
|---|---|
OIDC_ISSUER_URL |
OIDC issuer (auto-discovered via /.well-known/openid-configuration) |
OIDC_CLIENT_ID |
Client registered on the IdP |
OIDC_CLIENT_SECRET |
IdP client secret |
PROXY_BASE_URL |
Public URL of this proxy (audience-bound into every sealed token) |
UPSTREAM_MCP_URL |
Upstream MCP URL with explicit path (http://mcp:8000/mcp); the path is the proxy's mount AND forwarded verbatim. Origin-only, fragment-bearing, or control-plane-colliding paths are rejected at startup |
TOKEN_SIGNING_SECRET |
≥ 32 bytes, AES-GCM key; byte-identical across replicas. Generate with manifests/scripts/generate-signing-secret.sh (64-char base64). The startup validator rejects three weak-secret shapes: all-same-byte, short-repeating-period, and tiny alphabet (< 8 distinct values). Under PROD_MODE=true weak secrets fail fast. Rotation procedure (with TOKEN_SIGNING_SECRETS_PREVIOUS for zero-downtime rollover) in docs/runbooks/key-rotation.md |
Optional knobs (rate limits, replay store tuning, header trust,
observability, dev/compat) are documented indocs/configuration.md.
Production posture
PROD_MODE=true by default — the proxy fails startup if any
compatibility flag that weakens a security control is set. The
shipped defaults give you, with no extra effort:
- Redis required —
REDIS_REQUIRED=trueblocks startup withoutREDIS_URL. Stateless mode (codes/refresh replayable within TTL)
is dev-only. - PKCE required —
PKCE_REQUIRED=true. Clients without PKCE
(Cursor, MCP Inspector) need an explicit operator override. - Consent page on —
RENDER_CONSENT_PAGE=true. Closes the
silent-token-issuance phishing path. - Per-IP rate limiting on — every pre-auth endpoint plus the
authenticated MCP route. Per-replica scope: the limiter is
in-process; anN-replica deployment admits up toN ×the
documented per-endpoint rate. SizeTRUSTED_PROXY_CIDRSand any
upstream WAF accordingly. - Strict state on
/authorize—COMPAT_ALLOW_STATELESS=false.
The proxy refuses requests without a client-supplied state. - Forwarded-header allowlist enforced —
TRUSTED_PROXY_CIDRS
must be set if you want the rate limiter to honourX-Forwarded-*.
Wildcard trust (TRUST_PROXY_HEADERS=truewithout a CIDR list)
fails startup.
Set PROD_MODE=false only for single-replica dev / debugging that
needs one of the relaxation toggles. The CI manifest gate
(manifest-prod job) enforces this posture on the shipped overlay.
Architecture at a glance
Everything transient is sealed, not stored. Client registrations,
authorize sessions, authorization codes, access tokens, refresh
tokens — each one is an AES-GCM blob carrying its own TTL and an
audience matching PROXY_BASE_URL. No application database is
required. Redis is required by default for replay protection
(single-use authorization codes, refresh-rotation reuse detection,
single-use consent + callback-state tokens) — the sealed payloads
alone remain replayable within their TTL.
| Flow state | Encrypted into | TTL |
|---|---|---|
| Client registration | client_id |
7d (configurable via CLIENT_REGISTRATION_TTL) |
| Authorize session | IdP state parameter |
10min |
| Authorization code | code parameter |
60s |
| Access token | Opaque bearer | 1h |
| Refresh token | Opaque bearer | 7d |
Every payload verifies its audience on open. Two deployments that
accidentally share a TOKEN_SIGNING_SECRET but differ onPROXY_BASE_URL cannot replay each other's tokens — tested
across every sealed type.
See specs.md for the full trade-off table,
revocation rollout notes, and the K8s deployment shape.
Endpoints
| Path | Purpose |
|---|---|
GET /.well-known/oauth-protected-resource |
RFC 9728 resource metadata |
GET /.well-known/oauth-protected-resource<mount> |
RFC 9728 §3.1 per-resource variant |
GET /.well-known/oauth-authorization-server |
RFC 8414 AS metadata |
POST /register |
RFC 7591 dynamic client registration |
GET /authorize |
PKCE authorization endpoint (renders consent page by default) |
POST /consent |
Consent-page Approve / Deny submission |
GET /callback |
OIDC callback from the IdP |
POST /token |
authorization_code + refresh_token grants |
GET /healthz |
Liveness probe (always 200 while the process is up) |
GET /readyz (port 9090) |
Readiness probe on the metrics listener (NOT the public router); reflects Redis reachability |
| MCP mount + sub-paths | Reverse-proxied to UPSTREAM_MCP_URL after Bearer check |
GET /metrics (port 9090) |
Prometheus metrics |
Per-endpoint contract details (params, error shapes, replay-claim
ordering) live in specs.md.
Observability
- Structured logs — zap, JSON in production, console on a TTY.
Every request carries arequest_id(in the log AND theX-Request-Idresponse header — inbound is stripped). Authenticated
requests carrysubandemail. - Metrics — Prometheus on a dedicated port (
:9090, loopback-only
by default). Series families:mcp_auth_tokens_issued_total{grant_type}mcp_auth_authorize_initiated_total{path}— funnel entrymcp_auth_consent_decisions_total{decision}— funnel approve/denymcp_auth_access_denied_total{reason}— every denial bucketmcp_auth_replay_detected_total{kind}—code/refresh/consent/callback_statemcp_auth_rate_limited_total{endpoint}— pre-auth httprate 429smcp_auth_idp_exchange_throttled_total— outbound bucket denialsmcp_auth_clients_registered_total,mcp_auth_token_seals_total{purpose},mcp_auth_groups_claim_shape_mismatch_totalmcp_auth_rpc_calls_total{tool}and friends — opt-in viaMCP_TOOL_METRICS=true(per-tool RPC traffic)
- Health —
GET /healthz(liveness, public router) andGET /readyz(metrics port; reflects Redis whenREDIS_URLis set,
cached ~1s to resist probe-flood amplification).
Full alerting playbook + PromQL recipes (consent funnel rate, seal
counter rotation alert, etc.) indocs/configuration.md.
Demo stack
manifests/ ships a turn-key local stack: Docker
Compose with Keycloak (pre-seeded realm + admin user), Redis, a
minimal MCP server, and the proxy itself wired end-to-end. Themanifests/k8s/ set is split between reference YAML templates and a
production-oriented kustomize overlay atmanifests/overlays/production. manifests/scripts/generate-signing-secret.sh
emits a 64-character cryptographically-random base64 string suitable
for TOKEN_SIGNING_SECRET.
Building
./build.sh local # local binary only
./build.sh docker # docker image only
./build.sh # both
build.sh injects Version, CommitHash, BuildTimestamp,Builder, and ProjectURL via -ldflags -X. CI
(release.yml) does the same on
tag pushes — native multi-arch builders for linux/amd64 andlinux/arm64, per-platform tags merged into a manifest list, GitHub
Release auto-created.
Release a new version:
git tag v1.2.3 && git push origin v1.2.3
Deploying on Kubernetes
Stateless → plain Deployment + Service. Required invariants
across replicas:
- Identical
TOKEN_SIGNING_SECRET(mount from aSecret, do not
generate per-pod). - Identical
PROXY_BASE_URL(public DNS, not a per-pod hostname). terminationGracePeriodSeconds ≥ SHUTDOWN_TIMEOUTso rolling
deploys don't chop SSE streams mid-flight.
A ready-to-adapt manifest shape sits atmanifests/overlays/production/
and at the bottom ofspecs.md.
Production posture guides:
docs/redis-production.md— what
"production Redis" means for this proxy (auth, TLS, HA, sizing).docs/conformance.md— spec claim matrix,
compatibility notes, current IdP evidence.docs/threat-model.md— STRIDE coverage
matrix.docs/release-checklist.md— checks
to run before and after publishing a release image.docs/runbooks/— key rotation, bulk
revocation, Redis outage, IdP outage, consent denials, client
registration expired.
Testing
go test ./... # unit + e2e (mock OIDC)
go test -tags=keycloak_e2e -run "^TestKeycloakE2E" -count=1 .
go test -race ./... # race detector
go test -cover ./... # coverage
The mock-IdP e2e (e2e_test.go) exercises registration → authorize →
callback → token → refresh → bearer-protected proxy. Thekeycloak_e2e build tag runs the same flows + four negative-path
tests against the Docker Compose demo stack with real Keycloak. CI
runs both paths automatically on every PR.
Verifying published images
Tagged releases are built byrelease.yml with SLSA
provenance (mode=max) + SBOM attestations embedded in the OCI
image index, and keyless cosign signatures over both the
per-platform image digests and the merged multi-arch index, anchored
in the Rekor transparency log.
Image tags strip the v prefix (ghcr.io/babs/mcp-auth-proxy:1.0.0)
while git tags carry it (v1.0.0). The identity regex below matches
the git tag form.
cosign verify \
--certificate-identity-regexp '^https://github\.com/babs/mcp-auth-proxy/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/babs/mcp-auth-proxy:1.0.0
Inspect provenance and SBOM:
docker buildx imagetools inspect ghcr.io/babs/mcp-auth-proxy:1.0.0 \
--format '{{json .Provenance}}' | jq
docker buildx imagetools inspect ghcr.io/babs/mcp-auth-proxy:1.0.0 \
--format '{{json .SBOM}}' | jq
A policy controller (Kyverno, Sigstore policy-controller, …) can
enforce these checks on every pull in a cluster — see each tool's
docs for the exact policy syntax.
License
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found