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 restartSIGHUP 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

SymptomCauseFix
401 with error="invalid_token" and audit auth_result: bad_audienceToken’s aud claim does not match auth.oauth.audienceReissue 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_scopesGrant the scope at the AS, or remove the scope from required_scopes
401 with error="invalid_token" and audit auth_result: bad_issuerToken’s iss claim does not match auth.oauth.issuerCheck trailing slashes — Auth0 and Okta include one; most custom AS do not
Startup error oauth: prime jwks: … and process exits 78mcpgw cannot reach auth.oauth.jwks_url at startupVerify jwks_url is correct and reachable from the mcpgw host; confirm the response Content-Type is application/json
Startup warn oauth metadata advertises listen addresslisten is 0.0.0.0:… or :… and public_url is unsetSet auth.oauth.public_url to the URL clients see, e.g. https://gw.acme.com/mcp; restart
Discovery endpoint returns 404auth.oauth.enabled is false, or metadata_path was customised and the old path is being fetchedConfirm auth.oauth.enabled: true and the correct path
Opaque API keys stopped working after adding OAuth configThe auth header is being sent but looksLikeJWT is failing on an opaque key that is ≥100 chars and has 3 dot-separated base64url segmentsThis 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_id claim values to rich client labels in audit and traces
  • (Phase 3+) Tool-search proxy, elicitation policy, MCP Apps governance