Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

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):

oauth:
  signing_key: "${OAUTH_SIGNING_KEY_A}"
  1. Generate the new key B: openssl rand -base64 32.
  2. Phase 1, teach every replica to verify B. Add B to previous_signing_keys while keeping A as signing_key, then roll-restart all replicas. Every replica now verifies both A and B, but all still sign with A, 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
  1. Phase 2, promote B to the signer. Set signing_key to B and move A into previous_signing_keys, then roll-restart. Because Phase 1 already taught every replica to verify B, a not-yet-restarted replica accepts the B-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
  1. Retire A. After the access-token TTL has elapsed (1 hour) since Phase 2 completed, no live token is signed with A. Remove it from previous_signing_keys and 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:

  1. Go to your Keycloak admin console
  2. Create a new client:
  3. Client ID: mcp-data-platform
  4. Client authentication: ON
  5. Valid redirect URIs: https://mcp.example.com/oauth/callback
  6. Note the client secret from the Credentials tab
  7. 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:

  1. Open Settings > MCP Servers
  2. Add a new server:
  3. Name: My Data Platform
  4. URL: https://mcp.example.com
  5. Client ID: claude-desktop
  6. 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:

database:
  dsn: "${DATABASE_URL}"

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_challenge and code_challenge_method=S256
  • The server validates the code_verifier during 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:

curl https://mcp.example.com/.well-known/oauth-authorization-server

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