Enable OAuth 2.1 authentication
This guide walks through enabling OAuth Bearer-token authentication on a running mcpgw instance. Use it when:
- Your MCP clients are issued JWT access tokens by an OAuth 2.1 Authorization Server (Auth0, Okta, Keycloak, Azure AD, custom)
- You need per-client identity in your audit log (
oauth_client_id) - You want clients to discover the AS automatically via RFC 9728
Already running mcpgw with API keys? OAuth and API keys can coexist — mcpgw inspects the
Authorization: Bearer …token and routes JWTs to the verifier and opaque tokens to the keystore. You don’t need to rip API keys out to add OAuth.
Prerequisites
- mcpgw vNEXT
- An OAuth 2.1 Authorization Server with a JWKS endpoint
- An access-token audience configured for mcpgw (e.g.
mcpgw-prod)
Steps
1. Configure mcpgw
Edit mcpgw.yaml:
auth:
enabled: true # must be true when auth.oauth.enabled is true
oauth:
enabled: true
# Issuer URL of your Authorization Server. Must match the `iss` claim
# in tokens exactly, including trailing-slash conventions.
issuer: "https://idp.acme.com/"
# Audience string your AS puts in the `aud` claim. Configure this in
# your AS as the mcpgw API audience.
audience: "mcpgw-prod"
# JWKS endpoint mcpgw fetches and caches to verify token signatures.
# For Auth0: https://<tenant>.auth0.com/.well-known/jwks.json
# For Okta: https://<org>.okta.com/oauth2/default/v1/keys
# For Keycloak: https://idp.acme.com/realms/<realm>/protocol/openid-connect/certs
jwks_url: "https://idp.acme.com/.well-known/jwks.json"
# Optional: scopes every token must carry. Requests without all listed
# scopes are rejected with 401 insufficient_scope.
required_scopes:
- mcp:read
# How long to cache the JWKS before re-fetching. Default: 5m.
# Tighter values reduce the JWKS-rotation window; looser values cut
# traffic to the AS.
jwks_cache_ttl: 5m
# Clock-skew tolerance for exp and nbf claims. Default: 30s.
leeway: 30s
# Public URL clients use to reach mcpgw's /mcp endpoint. Used in the
# RFC 9728 protected-resource metadata document and in WWW-Authenticate
# challenge headers. Required when listen is 0.0.0.0 (i.e., always in
# production); without it the metadata doc advertises the bind address.
public_url: "https://gw.acme.com/mcp"
# Optional: override the metadata document path.
# Default: /.well-known/oauth-protected-resource
# metadata_path: /.well-known/oauth-protected-resource
# API keys are optional alongside OAuth. Remove the keys block entirely
# if you only want OAuth auth.
keys: []
auth.oauth.enabled: true requires auth.enabled: true. If only OAuth is
configured, keys may be an empty list or omitted.
2. Reload or restart
Most auth.oauth changes take effect on SIGHUP:
# Docker
docker kill --signal=HUP mcpgw
# systemd
systemctl kill --signal=HUP mcpgw
# direct process
kill -HUP "$(pgrep mcpgw)"
public_url and metadata_path are baked into the HTTP mux at startup and
require a full restart — SIGHUP reloads the verifier but cannot
re-register route paths. All other auth.oauth fields (including jwks_url,
required_scopes, and leeway) hot-reload cleanly.
3. Mint a test token
Auth0 CLI:
auth0 test token \
--audience mcpgw-prod \
--scopes mcp:read
# The command prints the access token. Save it:
TOKEN=$(auth0 test token --audience mcpgw-prod --scopes mcp:read 2>/dev/null)
Raw curl against an OIDC token endpoint:
TOKEN=$(curl -s -X POST "https://idp.acme.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
--data-urlencode "audience=mcpgw-prod" \
--data-urlencode "scope=mcp:read" \
| jq -r .access_token)
Replace YOUR_CLIENT_ID and YOUR_CLIENT_SECRET with your AS’s machine-to-machine client credentials.
4. Send a request
curl -i -X POST https://gw.acme.com/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Expected: 200 OK, JSON-RPC response body.
A rejected token returns 401 with a WWW-Authenticate challenge:
WWW-Authenticate: Bearer realm="mcpgw",
resource_metadata="https://gw.acme.com/.well-known/oauth-protected-resource",
error="invalid_token"
5. Verify in audit and telemetry
tail -1 /var/log/mcpgw/audit.jsonl | jq
A successful OAuth request looks like:
{
"ts": "2026-05-09T14:23:01.123Z",
"session_id": "sess_01HXZ",
"method": "tools/list",
"action": "allow",
"auth_result": "ok",
"oauth_client_id": "[email protected]",
"scopes": "mcp:read",
"latency_ms": 4
}
oauth_client_id is sourced from the token’s client_id claim (RFC 9068),
with azp (authorized party) as a fallback for AS implementations such as
Keycloak that use that claim instead.
In traces, the same identity appears as span attributes mcp.auth.oauth_client_id
and mcp.auth.scopes.
6. Verify the discovery endpoint
curl -s https://gw.acme.com/.well-known/oauth-protected-resource | jq
Expected output (fields vary by config):
{
"resource": "https://gw.acme.com/mcp",
"authorization_servers": ["https://idp.acme.com/"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp:read"]
}
scopes_supported is omitted when required_scopes is empty.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 with error="invalid_token" and audit auth_result: bad_audience | Token’s aud claim does not match auth.oauth.audience | Reissue the token with the correct audience, or update auth.oauth.audience to match |
401 with error="insufficient_scope" | Token is missing a scope listed in required_scopes | Grant the scope at the AS, or remove the scope from required_scopes |
401 with error="invalid_token" and audit auth_result: bad_issuer | Token’s iss claim does not match auth.oauth.issuer | Check trailing slashes — Auth0 and Okta include one; most custom AS do not |
Startup error oauth: prime jwks: … and process exits 78 | mcpgw cannot reach auth.oauth.jwks_url at startup | Verify jwks_url is correct and reachable from the mcpgw host; confirm the response Content-Type is application/json |
Startup warn oauth metadata advertises listen address | listen is 0.0.0.0:… or :… and public_url is unset | Set auth.oauth.public_url to the URL clients see, e.g. https://gw.acme.com/mcp; restart |
Discovery endpoint returns 404 | auth.oauth.enabled is false, or metadata_path was customised and the old path is being fetched | Confirm auth.oauth.enabled: true and the correct path |
| Opaque API keys stopped working after adding OAuth config | The auth header is being sent but looksLikeJWT is failing on an opaque key that is ≥100 chars and has 3 dot-separated base64url segments | This is a token-shape collision — opaque keys with that shape are routed to the OAuth verifier and fail. Rename the key to avoid the JWT envelope pattern, or switch entirely to OAuth |
Coexisting with API keys
Both can be active in the same config block. Each request uses one path:
- JWT-shaped tokens (3 dot-separated base64url segments, ≥100 characters total) → OAuth verifier
- Everything else → API key store
There is no precedence setting. Token shape is the dispatch key, determined by
looksLikeJWT in the proxy. If only the OAuth verifier is loaded (no keys
configured), opaque tokens are rejected with 401 invalid.
auth:
enabled: true
oauth:
enabled: true
issuer: "https://idp.acme.com/"
audience: "mcpgw-prod"
jwks_url: "https://idp.acme.com/.well-known/jwks.json"
public_url: "https://gw.acme.com/mcp"
keys:
- id: ci-pipeline
hash: "argon2id$v=19$m=65536,t=3,p=2$..."
scopes: ["*"]
Disabling OAuth later
Set auth.oauth.enabled: false and send SIGHUP. The verifier goroutine stops,
in-flight requests that already started verification will complete, and the
discovery endpoint stops responding. If auth.keys is non-empty the gateway
falls back to API-key-only authentication. If both are disabled (or auth.enabled
is false), the gateway returns to unauthenticated mode.
What’s next
- (Phase 2) CIMD client metadata — mcpgw resolves
client_idclaim values to rich client labels in audit and traces - (Phase 3+) Tool-search proxy, elicitation policy, MCP Apps governance