OAuth 2.1 Server¶
mcp-data-platform includes a built-in OAuth 2.1 authorization server that enables Claude Desktop and other MCP clients to authenticate via your existing identity provider (e.g., Keycloak).
Use Cases¶
The built-in OAuth server supports two primary scenarios:
| Scenario | Description |
|---|---|
| Claude Desktop + IdP | Claude Desktop authenticates users via Keycloak/Auth0/Okta |
| Pre-registered Clients | Known clients with pre-configured credentials |
How It Works¶
The OAuth server acts as a bridge between MCP clients (like Claude Desktop) and your upstream identity provider:
sequenceDiagram
participant CD as Claude Desktop
participant MCP as MCP Server
participant KC as Keycloak
CD->>MCP: GET /oauth/authorize
MCP->>CD: 302 Redirect to Keycloak
CD->>KC: User logs in
KC->>CD: 302 Redirect with code
CD->>MCP: GET /oauth/callback?code=...
MCP->>KC: Exchange code for token
KC->>MCP: ID token + access token
MCP->>CD: 302 Redirect with MCP code
CD->>MCP: POST /oauth/token
MCP->>CD: MCP access token
CD->>MCP: Tool requests with Bearer token
Configuration¶
Basic Configuration (Pre-registered Clients)¶
For development or simple deployments with known clients:
server:
transport: http
address: ":8080"
oauth:
enabled: true
issuer: "http://localhost:8080"
# Pre-registered clients (no DCR needed)
clients:
- id: "claude-desktop"
secret: "${CLAUDE_CLIENT_SECRET}"
redirect_uris:
- "http://localhost"
- "http://127.0.0.1"
Full Configuration (with Keycloak)¶
For production deployments with Keycloak as the identity provider:
server:
transport: http
address: ":8080"
tls:
enabled: true
cert_file: /path/to/cert.pem
key_file: /path/to/key.pem
oauth:
enabled: true
issuer: "https://mcp.example.com"
# JWT signing key for access tokens (REQUIRED on http transport)
# Generate with: openssl rand -base64 32
signing_key: "${OAUTH_SIGNING_KEY}"
# Verify-only keys retained across a rotation (optional). See "Rotating the
# signing key" below.
# previous_signing_keys:
# - "${OAUTH_SIGNING_KEY_OLD}"
# Pre-registered client for Claude Desktop
clients:
- id: "claude-desktop"
secret: "${CLAUDE_CLIENT_SECRET}"
redirect_uris:
- "http://localhost"
- "http://127.0.0.1"
# Dynamic Client Registration (optional)
dcr:
enabled: false # Disabled by default for security
allowed_redirect_patterns:
- "^http://localhost.*"
- "^http://127.0.0.1.*"
# Upstream IdP (any OIDC-compliant provider; Keycloak shown)
upstream:
issuer: "https://keycloak.example.com/realms/mcp-demo"
client_id: "mcp-data-platform"
client_secret: "${KEYCLOAK_CLIENT_SECRET}"
redirect_uri: "https://mcp.example.com/oauth/callback"
# authorization_endpoint / token_endpoint are optional overrides; by default
# they are discovered from the issuer's well-known document (see below).
Configuration Reference¶
| Field | Required | Description |
|---|---|---|
oauth.enabled |
Yes | Enable the OAuth server |
oauth.issuer |
Yes | The OAuth issuer URL (your MCP server's public URL) |
oauth.signing_key |
Yes* | HMAC key for signing JWT access tokens (base64, 32+ bytes). *Required when oauth.enabled and server.transport: http (startup fails otherwise); on stdio it is auto-generated if omitted (tokens won't survive restart). Generate with: openssl rand -base64 32 |
oauth.previous_signing_keys |
No | Verify-only base64 keys retained across a signing-key rotation. New tokens are always signed with signing_key; tokens minted with a prior key still verify while it stays in this list. See Rotating the signing key |
oauth.allow_ephemeral_signing_key |
No | Permit an HTTP deployment to boot without signing_key, generating a per-process key instead (default: false). Unsafe for multi-replica deployments: each replica mints tokens its peers reject. Single-replica HTTP dev only |
oauth.clients |
No | Pre-registered OAuth clients |
oauth.clients[].id |
Yes | Client ID |
oauth.clients[].secret |
Yes | Client secret (use environment variable) |
oauth.clients[].redirect_uris |
Yes | Allowed redirect URIs |
oauth.dcr.enabled |
No | Enable Dynamic Client Registration |
oauth.dcr.allowed_redirect_patterns |
Yes, when DCR is enabled | Regex patterns for allowed redirect URIs. Registration is denied when empty unless allow_all_redirect_uris is set |
oauth.dcr.allow_all_redirect_uris |
No | Explicitly accept any HTTPS (or loopback HTTP) redirect URI without pattern matching. Not recommended: an attacker-controlled redirect URI enables authorization-code interception |
oauth.rate_limit.enabled |
No | Rate-limit /token and /register (default: true) |
oauth.rate_limit.trusted_proxies |
No | CIDRs whose X-Forwarded-For is trusted for client attribution (default: none). Empty trusts none: the direct peer address is used and forwarding headers are ignored. Set to your ingress/load-balancer CIDRs so per-client limiting works behind a proxy without being spoofable |
oauth.rate_limit.token.requests_per_minute |
No | Per-IP /token limit (default: 60) |
oauth.rate_limit.token.burst |
No | Per-IP /token burst (default: 10) |
oauth.rate_limit.register.requests_per_minute |
No | Per-IP /register limit (default: 10) |
oauth.rate_limit.register.burst |
No | Per-IP /register burst (default: 3) |
oauth.upstream.issuer |
No | Upstream IdP issuer URL. The authorization and token endpoints are discovered from <issuer>/.well-known/openid-configuration (see Upstream endpoint discovery) |
oauth.upstream.client_id |
No | MCP server's client ID in the upstream IdP |
oauth.upstream.client_secret |
No | MCP server's client secret |
oauth.upstream.redirect_uri |
No | Callback URL for upstream IdP |
oauth.upstream.authorization_endpoint |
No | Explicit authorization endpoint that bypasses discovery. Set only for IdPs with a broken or unreachable discovery document. Empty means discover |
oauth.upstream.token_endpoint |
No | Explicit token endpoint that bypasses discovery. Empty means discover. Discovery is skipped entirely only when both endpoints are set |
Upstream endpoint discovery¶
The broker resolves the upstream IdP's authorization and token endpoints via OIDC discovery: on first use it fetches <oauth.upstream.issuer>/.well-known/openid-configuration and reads the authorization_endpoint and token_endpoint from that document. This makes the brokered login flow work with any OIDC-compliant identity provider, not just Keycloak. Resolved endpoints are cached for the process lifetime.
A discovery failure is not cached: if the well-known document is unreachable at request time, /oauth/authorize returns a retryable server_error (HTTP 500) and the next request retries, rather than falling back to a fixed URL shape. The client-facing response carries only a generic description (upstream identity provider unavailable); the specific cause is logged server-side.
Each endpoint is resolved independently: an explicitly configured endpoint is used without any discovery, so a discovery outage never blocks a path whose endpoint is already known from config. For an IdP whose discovery document is broken or unreachable, set oauth.upstream.authorization_endpoint and oauth.upstream.token_endpoint explicitly. Setting both skips discovery entirely; setting only one lets that path work from config while the other is still discovered.
Rotating the signing key¶
Access tokens are HS256-signed and carry a kid (key id) header derived from the signing key, so a verifier can pick the right key after a rotation. New tokens are always signed with oauth.signing_key; oauth.previous_signing_keys holds additional keys for verification only. This lets you rotate without invalidating live sessions.
On a multi-replica deployment, rotate in two phases so that every replica can verify the new key before any replica starts signing with it. A single-phase swap (change signing_key and roll-restart in one step) causes intermittent 401s: during the rolling restart a not-yet-restarted replica does not yet know the new key and rejects tokens a restarted replica already signed with it.
Start state (key A in use):
- Generate the new key
B:openssl rand -base64 32. - Phase 1, teach every replica to verify
B. AddBtoprevious_signing_keyswhile keepingAassigning_key, then roll-restart all replicas. Every replica now verifies bothAandB, but all still sign withA, so no token carries the new key yet:
oauth:
signing_key: "${OAUTH_SIGNING_KEY_A}" # still signing with A
previous_signing_keys:
- "${OAUTH_SIGNING_KEY_B}" # B is verify-only for now
- Phase 2, promote
Bto the signer. Setsigning_keytoBand moveAintoprevious_signing_keys, then roll-restart. Because Phase 1 already taught every replica to verifyB, a not-yet-restarted replica accepts theB-signed tokens a restarted replica issues, so there are no 401s during the rollout:
oauth:
signing_key: "${OAUTH_SIGNING_KEY_B}" # now signing with B
previous_signing_keys:
- "${OAUTH_SIGNING_KEY_A}" # A retained for in-flight tokens
- Retire
A. After the access-token TTL has elapsed (1 hour) since Phase 2 completed, no live token is signed withA. Remove it fromprevious_signing_keysand roll-restart to complete the rotation.
Tokens issued before kid support existed carry no kid and are verified against the current key, then each previous key, so an in-place upgrade to this version does not log anyone out.
Upgrade Note: Signing Key Required on HTTP¶
Before this version, an HTTP deployment (server.transport: http or sse) with OAuth enabled but no oauth.signing_key booted with an auto-generated per-process key and a warning. That is unsafe for multiple replicas (each replica signs tokens its peers reject), so the platform now fails to start in that configuration, naming the missing oauth.signing_key. Fix it by configuring a persistent oauth.signing_key (the correct action for any real deployment). For a single-replica dev or demo install where an ephemeral key is acceptable, set oauth.allow_ephemeral_signing_key: true to restore the previous boot-with-warning behavior. stdio deployments are unaffected (single-process by construction, key still auto-generated when omitted).
Endpoints¶
When enabled, the OAuth server exposes:
| Endpoint | Method | Description |
|---|---|---|
/.well-known/oauth-authorization-server |
GET | Server metadata |
/oauth/authorize |
GET | Authorization endpoint (redirects to upstream IdP) |
/oauth/callback |
GET | Callback from upstream IdP |
/oauth/token |
POST | Token endpoint |
/oauth/register |
POST | Dynamic Client Registration (if enabled) |
Claude Desktop Setup¶
1. Configure Keycloak¶
Create a client in Keycloak for the MCP server:
- Go to your Keycloak admin console
- Create a new client:
- Client ID:
mcp-data-platform - Client authentication: ON
- Valid redirect URIs:
https://mcp.example.com/oauth/callback - Note the client secret from the Credentials tab
- Create test users as needed
2. Configure MCP Server¶
oauth:
enabled: true
issuer: "https://mcp.example.com"
clients:
- id: "claude-desktop"
secret: "your-client-secret"
redirect_uris:
- "http://localhost"
- "http://127.0.0.1"
upstream:
issuer: "https://keycloak.example.com/realms/your-realm"
client_id: "mcp-data-platform"
client_secret: "${KEYCLOAK_CLIENT_SECRET}"
redirect_uri: "https://mcp.example.com/oauth/callback"
3. Configure Claude Desktop¶
In Claude Desktop, add your MCP server:
- Open Settings > MCP Servers
- Add a new server:
- Name: My Data Platform
- URL:
https://mcp.example.com - Client ID:
claude-desktop - Client Secret: (the secret you configured)
When you connect, Claude Desktop will:
1. Open your browser to the MCP server's /oauth/authorize endpoint
2. Redirect you to Keycloak to log in
3. After login, redirect back to the MCP server
4. Complete the OAuth flow and connect
Storage¶
The OAuth server uses in-memory storage by default, which is suitable for:
- Development and testing
- Single-instance deployments
- Stateless deployments where tokens can be re-issued
For production multi-instance deployments, PostgreSQL storage is available:
When a database is configured, in-flight authorization state (the link
between an /oauth/authorize redirect and its upstream IdP callback) is also
stored in PostgreSQL. This matters behind a load balancer: the callback can
land on a different replica than the one that started the flow, and browser
login would fail if the state lived only in one replica's memory.
Refresh tokens and authorization codes are stored only as hex-encoded SHA-256 digests (both in PostgreSQL and in the in-memory store); the server hashes the presented value on lookup. A database read, backup, or replica therefore never yields a usable bearer credential.
Upgrade Note: Hashed Refresh Tokens¶
Migration 000078 converts pre-existing plaintext refresh tokens and
authorization codes to digests in place, so live sessions stay valid across
the upgrade. During a multi-replica rolling upgrade, replicas still running
the previous binary cannot validate the hashed rows, and any refresh token
such a replica issues after the migration ran is invalid under the new
binary; affected clients recover with one interactive re-authorization.
Complete the rollout promptly to minimize that window. Rolling this
migration back deletes all outstanding refresh tokens and authorization
codes (hashing is one-way), which forces every client through a full
interactive re-authorization.
Upgrade Note: Access Token Audience¶
Access tokens mint aud as the issuer URL (the platform is both the
authorization server and the resource server, RFC 9068); the requesting
client is carried in a client_id claim, and the authenticator rejects
tokens minted for any other audience. Tokens issued by versions that set
aud to the client id fail validation after an upgrade: clients receive a
401, silently refresh, and get a token with the new audience. During a
multi-replica rolling upgrade this self-heals the same way once the rollout
completes; expect brief 401-plus-refresh cycles while old and new replicas
coexist.
PKCE Support¶
PKCE (Proof Key for Code Exchange) is required for all authorization requests:
- Clients must provide
code_challengeandcode_challenge_method=S256 - The server validates the
code_verifierduring token exchange - This prevents authorization code interception attacks
Dynamic Client Registration¶
Security Consideration
DCR allows unknown clients to register. For production deployments with sensitive data, prefer pre-registered clients.
Enabling DCR requires configuring allowed_redirect_patterns: the registration endpoint is unauthenticated, and an unrestricted redirect URI is the setup for authorization-code interception, so registration is denied when no patterns are configured (the server logs a startup warning for this misconfiguration). Set allow_all_redirect_uris: true to explicitly opt out of pattern matching.
Scheme rules: plain HTTP redirect URIs are rejected for non-loopback hosts regardless of configuration; loopback HTTP (localhost, 127.0.0.1, [::1]) follows the RFC 8252 Section 7.3 native-app pattern and is accepted. Private-use schemes (RFC 8252 Section 7.1, e.g. com.example.app:/callback) are accepted only through an explicitly configured pattern, never through allow_all_redirect_uris.
If DCR is enabled, clients can register:
curl -X POST https://mcp.example.com/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-mcp-client",
"redirect_uris": ["http://localhost:8080/callback"]
}'
Response:
{
"client_id": "generated-client-id",
"client_secret": "generated-client-secret",
"client_name": "my-mcp-client",
"redirect_uris": ["http://localhost:8080/callback"]
}
Security Features¶
| Feature | Description |
|---|---|
| JWT Access Tokens | Self-validating signed JWTs containing user claims and roles |
| PKCE Required | All clients must use PKCE with S256 |
| Bcrypt Secrets | Client secrets stored as bcrypt hashes |
| Hashed Credentials at Rest | Refresh tokens and authorization codes stored only as SHA-256 digests |
| State Validation | CSRF protection via state parameter |
| Token Expiration | Access tokens expire after 1 hour |
| Refresh Token Rotation | New refresh token issued on each use |
| Endpoint Rate Limiting | /token and /register limited per-IP with a global backstop; trusted-proxy-aware attribution (see oauth.rate_limit) |
| DCR Registration Cleanup | Dynamically-registered clients never issued a token are reaped 24h after registration, bounding oauth_clients growth |
Troubleshooting¶
"Invalid redirect_uri": - Ensure the redirect URI exactly matches a configured pattern - Check for trailing slashes or port mismatches
"upstream IdP not configured":
- Add the oauth.upstream configuration block
- Verify the upstream issuer URL is correct
"authorization state not found": - The OAuth flow may have timed out (states expire after 10 minutes) - Restart the authorization flow
"token_exchange_failed": - Check Keycloak client secret is correct - Verify the callback URL matches Keycloak's valid redirect URIs - Check network connectivity to Keycloak
Verifying Setup¶
Check the OAuth metadata endpoint:
Expected response:
{
"issuer": "https://mcp.example.com",
"authorization_endpoint": "https://mcp.example.com/oauth/authorize",
"token_endpoint": "https://mcp.example.com/oauth/token",
"registration_endpoint": "https://mcp.example.com/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]
}
Next Steps¶
- OIDC Authentication - Direct OIDC without the OAuth server
- API Keys - Service account authentication
- Personas - Role-based tool filtering