McpAuth is the authentication and authorization component of the MCP Gateway Proof of Concept (PoC) described in the paper:
Simplified and Secure MCP Gateways for Enterprise AI Integration
Ivo Brett, CISSP, B.Eng, MSc
View Paper (2025)
This repository is part of a broader initiative to enable secure, scalable, and compliant enterprise integration with the Model Context Protocol (MCP). See the website SelfHostedMCP.com. It provides an extensible OAuth2.1-based authentication gateway that offloads identity, authorization, and policy management from backend MCP servers—ensuring conformance with the 2025-03-26 MCP Specification.
---McpAuth is designed to:
- Decouple security logic from MCP servers
- Centralize identity management using OAuth 2.1 & OIDC
- Support dynamic client registration
- Enforce fine-grained token scopes and policy controls
- Act as a composable module in enterprise-grade Zero Trust architectures
This implementation is part of a larger PoC that validates:
- A reference MCP Gateway architecture for secure deployments
- Threat model mapping aligned with frameworks such as MAESTRO and Narajala & Habler
- Real-world compatibility with tools like Cloudflare Tunnels, WireGuard, Traefik, and CrowdSec
The full proof of concept includes:
- Two isolated MCP servers (local and cloud-based)
- Secure tunneling via WireGuard and Pangolin
- Centralized intrusion detection and observability
- Seamless integration with Anthropic's MCP Inspector
- OAuth2 authentication with PKCE via Traefik
forwardAuth - Seamless integration with MCP Gateway SSE endpoints
- Email whitelisting for controlled access
- Docker-ready, easy to deploy
- Includes a Python-based test server
Go to the Google Cloud Console Navigate to APIs & Services > Credentials Click Create Credentials → OAuth client ID Choose Web Application Add an Authorized redirect URI — you’ll get this later when you set up Traefik, but it will look like: https://oauth.yourdomain.com/callback
Save the Client ID and Client Secret for later use.
MCPAuth now supports Keycloak as an identity provider. This is useful for enterprise environments that want to use their existing Keycloak infrastructure.
For complete MCP + Keycloak setup instructions, see the official guide: MCP Security Authorization with Keycloak
- Use an existing realm (e.g.,
master) or create a new one
Basic Settings:
- Go to your realm → Clients → Create
- Client ID:
mcp-server(or your preferred ID) - Client Protocol:
openid-connect - Access Type / Client Authentication:
confidential - Save the client
Settings Tab:
Valid Redirect URIs (add ALL of these):
https://oauth.yourdomain.com/callback
http://localhost:*/oauth/callback
http://localhost:*/oauth/callback/debug
Explanation:
- First line: Production callback for your mcpauth server
- Second line: Development - MCP clients running locally (wildcard port)
- Third line: MCP Inspector debug interface (includes
/debugsuffix)
Web Origins (for CORS - add ALL of these):
https://oauth.yourdomain.com
http://localhost:*
+
Explanation:
- Allows CORS requests from your production domain
- Allows CORS from any localhost port (development)
+= Keycloak shortcut meaning "allow all redirect URIs as origins"
This step is required for mcpauth to validate tokens properly.
- Go to Client Scopes or Mappers tab
- Click Add Mapper → By Configuration → Audience
- Name:
audience-config - Mapper Type:
Audience - Included Client Audience: Leave empty
- Included Custom Audience:
https://oauth.yourdomain.com - Add to ID token: OFF
- Add to access token: ON
- Save
Important: Set the audience to your mcpauth gateway URL, not individual MCP server URLs. All MCP servers protected by mcpauth should use the same audience.
- Go to the Credentials tab
- Copy the Client Secret
- Save this for your
KEYCLOAK_CLIENT_SECRETenvironment variable
Keycloak Environment Variables:
KC_PROXY=edge
KC_HOSTNAME=keycloak.yourdomain.com
KC_HOSTNAME_PORT=443
KC_HOSTNAME_STRICT=false
KC_HTTP_ENABLED=trueRun Keycloak with:
docker run -p 127.0.0.1:9080:8080 \
-e KC_PROXY=edge \
-e KC_HOSTNAME=keycloak.yourdomain.com \
-e KC_HOSTNAME_PORT=443 \
quay.io/keycloak/keycloak start-devIf using Traefik, you must make Keycloak's OAuth endpoints publicly accessible (no authentication required):
http:
routers:
# Public Keycloak OAuth endpoints - NO AUTHENTICATION
keycloak-public:
rule: "Host(`keycloak.yourdomain.com`) && (PathPrefix(`/realms/{realm}/.well-known`) || PathPrefix(`/realms/{realm}/protocol/openid-connect`) || PathPrefix(`/realms/{realm}/resources`))"
priority: 300 # Higher priority than authenticated routes
service: keycloak-service
entrypoints:
- websecure
tls:
certResolver: letsencrypt
# NO auth middleware here!
# Protected Keycloak admin endpoints - WITH AUTHENTICATION
keycloak-admin:
rule: "Host(`keycloak.yourdomain.com`)"
priority: 200
service: keycloak-service
middlewares:
- your-auth-middleware
entrypoints:
- websecure
tls:
certResolver: letsencrypt
services:
keycloak-service:
loadBalancer:
servers:
- url: "http://keycloak:8080"Critical Paths That Must Be Public:
/.well-known/oauth-authorization-server- OAuth discovery/.well-known/openid-configuration- OpenID Connect discovery/protocol/openid-connect/auth- Authorization endpoint/protocol/openid-connect/token- Token endpoint/protocol/openid-connect/certs- Public keys (JWKS)/resources/*- Static resources
| Variable | Default | Description |
|---|---|---|
PROVIDER |
(none) | Set to keycloak to use Keycloak |
CLIENT_ID |
(none) | Keycloak client ID |
CLIENT_SECRET |
(none) | Keycloak client secret |
KEYCLOAK_AUTH_HOST |
localhost |
Keycloak server host |
KEYCLOAK_AUTH_PORT |
8080 |
Keycloak server port |
KEYCLOAK_AUTH_PROTOCOL |
https |
Keycloak protocol (http or https) |
KEYCLOAK_REALM |
master |
Keycloak realm name |
PROVIDER=keycloak
CLIENT_ID=mcp-server
CLIENT_SECRET=your-keycloak-client-secret
KEYCLOAK_AUTH_HOST=keycloak.yourdomain.com
KEYCLOAK_AUTH_PORT=443
KEYCLOAK_AUTH_PROTOCOL=https
KEYCLOAK_REALM=your-realm
OAUTH_DOMAIN=oauth.yourdomain.comgo run cmd/main.go \
-provider=keycloak \
-clientID=mcp-server \
-clientSecret=your-client-secret \
-keycloakAuthHost=localhost \
-keycloakAuthPort=8080 \
-keycloakRealm=master \
-oauthDomain=oauth.yourdomain.comOr using Docker Compose:
services:
mcpauth:
image: oideibrett/mcpauth:latest
environment:
- PROVIDER=keycloak
- CLIENT_ID=mcp-server
- CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_AUTH_HOST=keycloak
- KEYCLOAK_AUTH_PORT=8080
- KEYCLOAK_REALM=master
- OAUTH_DOMAIN=oauth.yourdomain.com
ports:
- "11000:11000"
keycloak:
image: quay.io/keycloak/keycloak:latest
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
command: start-dev
ports:
- "8080:8080"# Check MCP protected resource metadata
curl https://your-mcp-server.com/.well-known/oauth-protected-resource/mcp
# Should return:
# {
# "authorization_servers": ["https://keycloak.yourdomain.com/realms/master/"],
# "resource": "https://your-mcp-server.com",
# "scopes_supported": ["openid", "email"]
# }
# Check Keycloak OAuth metadata
curl https://keycloak.yourdomain.com/realms/master/.well-known/oauth-authorization-server
# Should return Keycloak's full OAuth configuration with endpoints- Install MCP Inspector:
npm install -g @modelcontextprotocol/inspector - Run:
mcp-inspector - Add your protected MCP server URL
- Click "Connect" - you should be redirected to Keycloak login
- After login, you should be redirected back and connected
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"protected-server": {
"url": "https://your-mcp-server.com"
}
}
}Restart Claude Desktop and try to use the MCP server - you'll be prompted for Keycloak authentication.
"Page not found" when redirected to Keycloak:
- Check that Keycloak's OAuth endpoints are NOT protected by authentication
- Verify the Traefik public router configuration (priority 300)
- Test:
curl https://keycloak.yourdomain.com/realms/master/.well-known/oauth-authorization-server
"Invalid redirect URI" error:
- Add the exact redirect URI to Keycloak client settings
- For MCP Inspector, include:
http://localhost:*/oauth/callback/debug - For production, include:
https://oauth.yourdomain.com/callback
CORS errors in browser:
- Configure Web Origins in Keycloak client
- Add
http://localhost:*for development - Add
+to allow all redirect URIs as origins
"Audience validation failed" errors:
- Verify the Audience Mapper is configured correctly
- Audience should be:
https://oauth.yourdomain.com(your mcpauth gateway) - Check mcpauth logs for the expected audience value
Token introspection fails:
- Verify
KEYCLOAK_AUTH_HOST,KEYCLOAK_AUTH_PORT, andKEYCLOAK_AUTH_PROTOCOLare correct - Test introspection endpoint:
curl -X POST https://keycloak.yourdomain.com/realms/master/protocol/openid-connect/token/introspect
CLIENT_ID=<INSERT_VALUE_FROM_GOOGLE>
CLIENT_SECRET=<INSERT_VALUE_FROM_GOOGLE>
Use flags or environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
11000 |
Port for the auth server |
PROTECTED_PATH |
/sse |
Protected endpoint path |
OAUTH_DOMAIN |
(none) | OAuth issuer domain |
CLIENT_ID |
(none) | OAuth client ID |
CLIENT_SECRET |
(none) | OAuth client secret |
ALLOWED_EMAILS |
(none) | Comma-separated list of allowed emails |
LOG_LEVEL |
1 |
0=debug, 1=info, 2=minimal |
MCPAuth supports fine-grained scope control to enhance security by limiting token privileges. You can define which scopes are allowed in an OAuth request and which are required for a token to be considered valid.
- Allowed Scopes: A whitelist of scopes that the middleware is permitted to request from the OAuth provider. If a client requests scopes not in this list, they will be ignored.
- Required Scopes: A list of scopes that must be present in the granted token after the user authenticates. If the token does not contain all of these scopes, access will be denied with a
403 Forbidden (Insufficient Scope)error.
This allows you to enforce policies like requiring an email scope for all users while allowing clients to optionally request additional permissions like profile or custom API scopes.
You can configure scopes using command-line flags or environment variables:
| Variable | Flag | Description |
|---|---|---|
ALLOWED_SCOPES |
-allowedScopes |
Comma-separated list of allowed OAuth scopes. |
REQUIRED_SCOPES |
-requiredScopes |
Comma-separated list of required OAuth scopes. |
Example:
To allow clients to request openid, email, and profile scopes, but require that all valid tokens include at least openid and email, you would set:
ALLOWED_SCOPES=openid,email,profileREQUIRED_SCOPES=openid,email
services:
mcpauth:
image: oideibrett/mcpauth:latest
environment:
- PORT=11000
- CLIENT_ID=${CLIENT_ID}
- CLIENT_SECRET=${CLIENT_SECRET}
ports:
- "11000:11000"
traefik:
image: traefik::v3.4.1
command:
- "--providers.docker=true"
- "--entrypoints.websecure.address=:443"
ports:
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock- Go 1.21+
- Traefik v2.x+
- An OAuth provider (e.g., Google, GitHub)
git clone https://github.com/oidebrett/mcpauth
cd mcpauth
go mod tidygo run cmd/main.go -port=11000 -oauthDomain=your-domain.comdocker buildx build --platform linux/amd64,linux/arm64 -t oideibrett/mcpauth:dev --push .services:
mcpauth:
build: .
environment:
- PORT=11000
- CLIENT_ID=${CLIENT_ID}
- CLIENT_SECRET=${CLIENT_SECRET}
ports:
- "11000:11000"http:
middlewares:
mcp-auth:
forwardAuth:
address: "http://mcpauth:11000/auth"
authResponseHeaders:
- "X-Forwarded-User"labels:
- "traefik.http.routers.myapp.middlewares=mcp-auth@file"cd test_mcp_server
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python mcp-server-sse.pyHere are a few curl commands to test different authentication and authorization scenarios.
1. Health Check
This command checks if the mcpauth service is running and responsive. You should receive a 200 OK response.
curl -i http://localhost:11000/health2. Accessing a Protected Endpoint (No Token)
When you try to access a protected endpoint like /sse without a valid token, mcpauth should initiate the OAuth2 authentication flow. For a non-browser client like curl, this will result in a 302 Found redirect to the Google login page.
curl -i http://localhost:11000/sseExpected Output: An HTTP 302 Found redirecting to `https://accounts.google.com/...
3. Accessing a Protected Endpoint (Valid Token)
Once you have a valid bearer token from the OAuth provider, you can use it to access the protected endpoint. This request should be successful (200 OK), and mcpauth will forward the request to the upstream service.
# Replace YOUR_VALID_TOKEN with an actual bearer token
curl -i -H "Authorization: Bearer YOUR_VALID_TOKEN" http://localhost:11000/sseExpected Output: An HTTP 200 OK and the response from the test server (e.g., the SSE stream).
4. Accessing a Protected Endpoint (Invalid/Expired Token)
If you use a token that is invalid, malformed, or expired, mcpauth should deny access. This will likely result in a 401 Unauthorized error, prompting for re-authentication.
curl -i -H "Authorization: Bearer INVALID_TOKEN" http://localhost:11000/sseExpected Output: An HTTP 401 Unauthorized response.
5. Accessing with a Token from an Unauthorized User
If the ALLOWED_EMAILS list is configured, mcpauth will validate the user's email from the token. If the user is not on the whitelist, access will be denied.
# Use a valid token from a user whose email is NOT in ALLOWED_EMAILS
curl -i -H "Authorization: Bearer TOKEN_FROM_UNAUTHORIZED_USER" http://localhost:11000/sseExpected Output: An HTTP 403 Forbidden response.
Apply middlewares in this order:
mcp-cors-headersredirect-regexmcp-auth
Example dynamic config:
http:
middlewares:
mcp-cors-headers:
headers:
accessControlAllowCredentials: true
accessControlAllowHeaders:
- Authorization
- Content-Type
- mcp-protocol-version
accessControlAllowMethods:
- GET
- POST
- OPTIONS
accessControlAllowOriginList:
- "*"
accessControlMaxAge: 86400
addVaryHeader: true
redirect-regex:
redirectRegex:
regex: "^https://[^/]+\.([^.]+\.[^./]+)/\.well-known/(.+)"
replacement: "https://oauth.${1}/.well-known/${2}"
permanent: true
mcp-auth:
forwardAuth:
address: "http://mcpauth:11000/sse"
authResponseHeaders:
- X-Forwarded-UserThis project supports middleware-manager.
Example templates.yml:
middlewares:
- id: mcp-auth
name: MCP Authentication
type: forwardAuth
config:
address: "http://mcpauth:11000/sse"
authResponseHeaders:
- "X-Forwarded-User"
- id: mcp-cors-headers
name: MCP CORS Headers
type: headers
config:
accessControlAllowMethods:
- GET
- POST
- OPTIONS
accessControlAllowOriginList:
- "*"
accessControlAllowHeaders:
- Authorization
- Content-Type
- mcp-protocol-version
accessControlMaxAge: 86400
accessControlAllowCredentials: true
addVaryHeader: true
- id: redirect-regex
name: Regex Redirect
type: redirectregex
config:
regex: "^https://[^/]+\.(yourdomain\.com)/\.well-known/(.+)"
replacement: "https://oauth.${1}/.well-known/${2}"
permanent: trueMCPAuth can act as a replacement for Cloudflare Access when protecting an OpenShell-style gateway behind Traefik. When enabled, it provides:
/auth/connect— Browser-based login relay that authenticates the user via your IdP (Keycloak, Google, or internal), issues aCF_AuthorizationJWT cookie, and serves a connect page that relays the token to the CLI's localhost callback server./_ws_tunnel— Forward-auth endpoint for Traefik that validates theCF_Authorizationcookie before allowing WebSocket connections through to the gateway./auth(existing) — Also accepts theCF_Authorizationcookie as a fallback token source when edge auth is enabled.
Edge auth runs in parallel with the existing OAuth/Bearer token flow. The same mcpauth instance can serve both MCP OAuth clients and OpenShell gateway browser logins using the same IdP.
| Variable | Flag | Default | Description |
|---|---|---|---|
ENABLE_EDGE_AUTH |
-enableEdgeAuth |
false |
Enable edge auth endpoints |
COOKIE_DOMAIN |
-cookieDomain |
OAUTH_DOMAIN |
Domain for the CF_Authorization cookie |
All other provider config (PROVIDER, CLIENT_ID, CLIENT_SECRET, Keycloak settings, etc.) is shared with the existing OAuth flow.
services:
mcpauth:
image: oideibrett/mcpauth:edge_auth
environment:
- ENABLE_EDGE_AUTH=true
- COOKIE_DOMAIN=.yourdomain.com
- OAUTH_DOMAIN=oauth.yourdomain.com
- PROVIDER=keycloak
- CLIENT_ID=mcp-server
- CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
- KEYCLOAK_AUTH_HOST=keycloak.yourdomain.com
- KEYCLOAK_AUTH_PORT=443
- KEYCLOAK_AUTH_PROTOCOL=https
- KEYCLOAK_REALM=master
restart: unless-stopped
# If using internal auth instead of Keycloak:
# mcpauth:
# image: oideibrett/mcpauth:edge_auth
# environment:
# - ENABLE_EDGE_AUTH=true
# - COOKIE_DOMAIN=.yourdomain.com
# - OAUTH_DOMAIN=oauth.yourdomain.com
# - DEV_MODE=false
# command: ["-useInternalAuth"]Edge auth requires three Traefik components: the edge-auth forwardAuth middleware, a high-priority login router (so the login flow isn't blocked by forwardAuth), and the gateway router with the middleware applied.
Add an edge-auth forwardAuth middleware. Traefik calls mcpauth's /_ws_tunnel endpoint to validate the CF_Authorization cookie before proxying to the gateway.
http:
middlewares:
edge-auth:
forwardAuth:
address: "http://mcpauth:11000/_ws_tunnel"
authResponseHeaders:
- X-Forwarded-User
- X-Forwarded-ScopesYou need two routers on the gateway domain with different priorities:
http:
routers:
# ── Login flow (NO middleware, priority 300) ─────────────────
# CRITICAL: The login paths must have a HIGHER priority than
# any router that applies the edge-auth middleware on the same
# domain. Otherwise forwardAuth intercepts the login redirects
# and returns 401 before the user can log in.
gateway-login:
rule: "Host(`gateway.yourdomain.com`) && (PathPrefix(`/auth/connect`) || PathPrefix(`/authorize`) || PathPrefix(`/callback`) || PathPrefix(`/internal`))"
service: mcpauth-service
priority: 300
entryPoints:
- websecure
tls:
certResolver: letsencrypt
# ── Gateway (PROTECTED by edge-auth, priority 250) ───────────
# All other requests to the gateway domain go through forwardAuth.
# The edge-auth middleware validates the CF_Authorization cookie;
# if valid (200), Traefik proxies to the gateway service.
# If invalid/missing (401), the request is blocked.
gateway-protected:
rule: "Host(`gateway.yourdomain.com`)"
service: gateway-service
middlewares:
- edge-auth
priority: 250
entryPoints:
- websecure
tls:
certResolver: letsencryptWhy two routers? The gateway's catch-all router applies
edge-authforwardAuth to all requests. But the login flow itself (/auth/connect→/authorize→ IdP →/callback) must reach mcpauth without authentication — these paths ARE the login. The higher-priority (300 vs 250) login router matches first and routes directly to mcpauth.
http:
services:
mcpauth-service:
loadBalancer:
servers:
- url: "http://mcpauth:11000"
gateway-service:
loadBalancer:
servers:
- url: "http://gateway:8080"The existing Bearer-token OAuth routes on the oauth.yourdomain.com domain are completely unaffected:
http:
middlewares:
mcp-auth:
forwardAuth:
address: "http://mcpauth:11000/auth"
authResponseHeaders:
- X-Forwarded-User
- X-Forwarded-Scopes
routers:
mcp-server:
rule: "Host(`mcp.yourdomain.com`)"
service: mcp-service
middlewares:
- mcp-auth
entrypoints:
- websecure
tls:
certResolver: letsencrypt- The CLI opens the user's browser to
https://oauth.yourdomain.com/auth/connect?callback_port=<port>&code=<code> - MCPAuth checks for a
CF_Authorizationcookie — none exists yet - MCPAuth redirects through the OAuth login flow (Keycloak, Google, or internal)
- After successful IdP login, MCPAuth mints a
CF_AuthorizationJWT cookie (24h TTL) and redirects back to/auth/connect - Now the cookie is present — MCPAuth serves the connect page showing the confirmation code
- The user clicks "Connect" — JavaScript POSTs the token to the CLI's localhost callback
- Subsequent
/_ws_tunnelWebSocket connections include the cookie and are validated by theedge-authforwardAuth middleware
- MCPAuth extracts the user's
emailfrom the Keycloak userinfo response. If no email is set on the Keycloak user, it falls back topreferred_username. - Ensure your Keycloak client has
emailandopenidin its client scopes so the email claim is returned.
# Start mcpauth with edge auth enabled (internal auth, dev mode)
go run cmd/main.go -devMode -useInternalAuth -enableEdgeAuth -port 11000 -oauthDomain localhost:11000
# 1. /auth/connect without cookie → redirects to login
curl -v 'http://localhost:11000/auth/connect?callback_port=9999&code=TEST-1234'
# Expect: 307 redirect to /authorize
# 2. /_ws_tunnel without cookie → 401
curl -v http://localhost:11000/_ws_tunnel
# Expect: 401 {"error":"authentication required"}
# 3. /_ws_tunnel with valid CF_Authorization cookie → 200
curl -v -H "Cookie: CF_Authorization=<jwt-from-login-flow>" http://localhost:11000/_ws_tunnel
# Expect: 200 with X-Forwarded-User header
# 4. /auth with CF_Authorization cookie → 200 (fallback token source)
curl -v -H "Cookie: CF_Authorization=<jwt-from-login-flow>" http://localhost:11000/auth
# Expect: 200 with X-Forwarded-User header
# 5. /_ws_tunnel with invalid cookie → 401
curl -v -H "Cookie: CF_Authorization=bad-token" http://localhost:11000/_ws_tunnel
# Expect: 401 {"error":"invalid or expired token"}Licensed under the GNU General Public License v3.0.

