mcp-auth-proxy

mcp
Security Audit
Warn
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.

SUMMARY

OAuth 2.1 + OIDC bridge for private MCP servers. Stateless, replay-safe, audit-defensible. Bring any IdP — Keycloak to Entra.

README.md

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.

Build
Go
License
Container


┌──────────────────┐      ┌────────────────────┐      ┌─────────────────┐
│ 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. See
    docs/redis-production.md for sizing.
  • Public HTTPS terminating at an ingress that reaches the proxy's
    LISTEN_ADDR (:8080 by default). The IdP and the MCP clients both
    see PROXY_BASE_URL over 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:


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 in
docs/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 requiredREDIS_REQUIRED=true blocks startup without
    REDIS_URL. Stateless mode (codes/refresh replayable within TTL)
    is dev-only.
  • PKCE requiredPKCE_REQUIRED=true. Clients without PKCE
    (Cursor, MCP Inspector) need an explicit operator override.
  • Consent page onRENDER_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; an N-replica deployment admits up to N × the
    documented per-endpoint rate. Size TRUSTED_PROXY_CIDRS and any
    upstream WAF accordingly.
  • Strict state on /authorizeCOMPAT_ALLOW_STATELESS=false.
    The proxy refuses requests without a client-supplied state.
  • Forwarded-header allowlist enforcedTRUSTED_PROXY_CIDRS
    must be set if you want the rate limiter to honour X-Forwarded-*.
    Wildcard trust (TRUST_PROXY_HEADERS=true without 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 on
PROXY_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 a request_id (in the log AND the
    X-Request-Id response header — inbound is stripped). Authenticated
    requests carry sub and email.
  • 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 entry
    • mcp_auth_consent_decisions_total{decision} — funnel approve/deny
    • mcp_auth_access_denied_total{reason} — every denial bucket
    • mcp_auth_replay_detected_total{kind}code / refresh /
      consent / callback_state
    • mcp_auth_rate_limited_total{endpoint} — pre-auth httprate 429s
    • mcp_auth_idp_exchange_throttled_total — outbound bucket denials
    • mcp_auth_clients_registered_total, mcp_auth_token_seals_total{purpose},
      mcp_auth_groups_claim_shape_mismatch_total
    • mcp_auth_rpc_calls_total{tool} and friends — opt-in via
      MCP_TOOL_METRICS=true (per-tool RPC traffic)
  • HealthGET /healthz (liveness, public router) and
    GET /readyz (metrics port; reflects Redis when REDIS_URL is set,
    cached ~1s to resist probe-flood amplification).

Full alerting playbook + PromQL recipes (consent funnel rate, seal
counter rotation alert, etc.) in
docs/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. The
manifests/k8s/ set is split between reference YAML templates and a
production-oriented kustomize overlay at
manifests/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 and
linux/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:

  1. Identical TOKEN_SIGNING_SECRET (mount from a Secret, do not
    generate per-pod).
  2. Identical PROXY_BASE_URL (public DNS, not a per-pod hostname).
  3. terminationGracePeriodSeconds ≥ SHUTDOWN_TIMEOUT so rolling
    deploys don't chop SSE streams mid-flight.

A ready-to-adapt manifest shape sits at
manifests/overlays/production/
and at the bottom of
specs.md.

Production posture guides:


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. The
keycloak_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 by
release.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

Apache License 2.0 — see LICENSE and
NOTICE.

Reviews (0)

No results found