MCP Auth with Keycloak and Ktor

6 min read
mcp keycloak ktor oauth authentication

The MCP spec defines an OAuth-based authorization flow for remote servers. The spec is clear about what your server needs to expose — well-known metadata endpoints, JWT validation, the works — but it stops short of showing you how to wire all of that to a real identity provider. If you are running Keycloak and building your MCP server with Ktor, this post covers the full integration.

The Discovery Flow

MCP clients follow a two-step discovery process before they ever send a tool call:

  1. Protected Resource Metadata (RFC 9728): The client hits /.well-known/oauth-protected-resource on your server to find out which authorization server to talk to.
  2. Authorization Server Metadata (RFC 8414): The client then fetches /.well-known/oauth-authorization-server from the same origin (your server proxies Keycloak’s metadata) to discover the authorization, token, and registration endpoints.

Once the client has this metadata, it runs a standard OAuth authorization code flow with PKCE directly against Keycloak, gets a token, and presents it as a Bearer token on subsequent MCP requests.

Publishing the Well-Known Endpoints

Your Ktor server needs to serve both metadata documents. I built a single routing function that takes the external Keycloak URL and realm name and constructs all the Keycloak endpoint URLs from those two values.

fun Route.oauthMetadataRouting(
    keycloakExternalUrl: String,
    realm: String,
) {
    val authServerBase = "$keycloakExternalUrl/realms/$realm"

    get("/.well-known/oauth-authorization-server") {
        val metadata = buildJsonObject {
            put("issuer", authServerBase)
            put("authorization_endpoint", "$authServerBase/protocol/openid-connect/auth")
            put("token_endpoint", "$authServerBase/protocol/openid-connect/token")
            put("registration_endpoint", "$authServerBase/clients-registrations/openid-connect")
            put("jwks_uri", "$authServerBase/protocol/openid-connect/certs")
            putJsonArray("response_types_supported") { add(JsonPrimitive("code")) }
            putJsonArray("grant_types_supported") {
                add(JsonPrimitive("authorization_code"))
                add(JsonPrimitive("refresh_token"))
            }
            putJsonArray("code_challenge_methods_supported") { add(JsonPrimitive("S256")) }
            putJsonArray("token_endpoint_auth_methods_supported") {
                add(JsonPrimitive("none"))
                add(JsonPrimitive("client_secret_basic"))
                add(JsonPrimitive("client_secret_post"))
            }
            putJsonArray("scopes_supported") {
                add(JsonPrimitive("profile"))
                add(JsonPrimitive("email"))
                add(JsonPrimitive("organization"))
                add(JsonPrimitive("offline_access"))
            }
        }
        call.respondText(metadata.toString(), ContentType.Application.Json)
    }

    get("/.well-known/oauth-protected-resource") {
        val metadata = buildJsonObject {
            put("resource", "https://app.capture-gtd.com")
            putJsonArray("authorization_servers") { add(JsonPrimitive(authServerBase)) }
            putJsonArray("scopes_supported") {
                add(JsonPrimitive("profile"))
                add(JsonPrimitive("email"))
                add(JsonPrimitive("organization"))
                add(JsonPrimitive("offline_access"))
            }
            putJsonArray("bearer_methods_supported") { add(JsonPrimitive("header")) }
        }
        call.respondText(metadata.toString(), ContentType.Application.Json)
    }
}

A few things to note:

  • registration_endpoint points to Keycloak’s OpenID Connect dynamic client registration endpoint. MCP clients use this to register themselves as OAuth clients.
  • code_challenge_methods_supported advertises only S256. PKCE is required by the MCP spec, and there is no reason to support the plain method.
  • token_endpoint_auth_methods_supported includes none because public MCP clients (like Claude Desktop) cannot hold a client secret.
  • The scopes list includes offline_access so clients can request refresh tokens for long-lived sessions.

Keycloak Client Registration

MCP clients need to dynamically register themselves as OAuth clients with Keycloak. This is one of the rougher edges of the integration today.

Keycloak ships with a trusted-hosts policy on its client registration endpoint. By default, only clients from whitelisted hostnames can register. MCP clients come from arbitrary origins (Claude Desktop, Cursor, custom agents), so I had to remove the trusted-hosts policy on the client registration in the Keycloak admin console.

This works, but it is not ideal. The MCP spec (2025-11-25 revision) introduced a more structured client registration approach, and Keycloak is actively working on native support for it. Once that lands, the workaround goes away and client registration becomes a first-class Keycloak feature.

Stateless MCP Sessions

The MCP Kotlin SDK supports both WebSocket-based and Streamable HTTP transports. I went with Streamable HTTP and made sessions fully stateless — every POST to /mcp creates a fresh transport and server instance, processes the request, and returns.

fun Route.authenticatedMcpRouting(
    gtdService: GtdService,
    commandContextFactory: CommandContextFactory,
) {
    fun createMcpServer(principal: JWTPrincipal): Server =
        Server(
            serverInfo = Implementation(
                name = "gtd-mcp-server",
                version = "1.0.0",
            ),
            options = ServerOptions(
                capabilities = ServerCapabilities(
                    tools = ServerCapabilities.Tools(listChanged = false),
                    prompts = ServerCapabilities.Prompts(listChanged = false),
                ),
            ),
        ) {
            "GTD MCP server. Authenticated access to GTD data."
        }.apply {
            registerGtdTools(
                gtdService = gtdService,
                commandContextFactory = commandContextFactory,
                principal = principal,
            )
            registerGtdPrompts()
        }

    authenticate("jwt-auth") {
        route("/mcp") {
            post {
                val principal = call.principal<JWTPrincipal>()!!
                val transport =
                    StreamableHttpServerTransport(enableJsonResponse = true).apply {
                        setSessionIdGenerator(null) // Stateless: no session tracking
                    }
                createMcpServer(principal).createSession(transport)
                transport.handlePostRequest(null, call)
            }
            get {
                call.respond(HttpStatusCode.MethodNotAllowed)
            }
            delete {
                call.respond(HttpStatusCode.MethodNotAllowed)
            }
        }
    }
}

The key design decisions here:

  • setSessionIdGenerator(null) disables session ID generation. The server does not track sessions across requests.
  • Fresh server per request. The JWTPrincipal is extracted from each request independently and injected into a new Server instance. There is no shared state to synchronize or clean up.
  • No GET or DELETE. The Streamable HTTP transport only needs POST. GET (for SSE streaming) and DELETE (for session termination) return 405.

This approach trades some overhead (creating a new server per request) for operational simplicity. No session store, no cleanup, no WebSocket connections to manage. For a tool-calling workload where each request is a short-lived command/query pair, the overhead is negligible.

This uses io.modelcontextprotocol:kotlin-sdk-server:0.8.4 with the Streamable HTTP transport from MCP spec revision 2025-03-26.

JWT Validation Gotchas

The JWT validation setup has several Keycloak-specific details that are easy to get wrong.

Internal vs. External URLs

When your Ktor server runs inside a Kubernetes cluster alongside Keycloak, you have two URLs for the same service:

  • External URL (https://auth.example.com): What users and MCP clients see. Keycloak stamps this into the iss claim of every token.
  • Internal URL (http://keycloak.auth.svc.cluster.local:8080): Cluster-internal address. Used for fetching JWKS keys without hitting the load balancer.

The issuer validation must use the external URL (because that is what is in the token), but the JWKS fetch should use the internal URL for reliability and performance.

val keycloakIssuer = "$keycloakExternalUrl/realms/$keycloakRealm"
val jwksUrl = "$keycloakInternalUrl/realms/$keycloakRealm/protocol/openid-connect/certs"

JWKS Provider Construction

One gotcha with the auth0/java-jwt library: if you pass a String to JwkProviderBuilder, it appends /.well-known/jwks.json to the URL. Keycloak uses /protocol/openid-connect/certs instead. You must pass a URL object to avoid this:

val jwkProvider = JwkProviderBuilder(URL(jwksUrl))  // URL, not String!
    .cached(10, 24, TimeUnit.HOURS)
    .rateLimited(10, 1, TimeUnit.MINUTES)
    .build()

The provider caches up to 10 keys for 24 hours and rate-limits JWKS fetches to 10 per minute.

No Audience Validation

Keycloak puts the client ID in the azp (authorized party) claim, not in aud. The aud claim often contains just "account". I skip audience validation entirely and rely on issuer validation plus signature verification:

install(Authentication) {
    jwt("jwt-auth") {
        realm = keycloakRealm
        verifier(jwkProvider, issuer = keycloakIssuer) {
            // No audience validation - Keycloak puts client ID in azp, not aud
            acceptLeeway(3) // 3 seconds clock skew tolerance
        }
        validate { credential ->
            if (credential.payload.issuer != keycloakIssuer) return@validate null
            JWTPrincipal(credential.payload)
        }
        challenge { _, _ ->
            call.response.headers.append(
                HttpHeaders.WWWAuthenticate,
                """Bearer resource_metadata="$serverExternalUrl/.well-known/oauth-protected-resource""""
            )
            call.respondText("Unauthorized", status = HttpStatusCode.Unauthorized)
        }
    }
}

The WWW-Authenticate Header

When a request fails JWT validation, the MCP spec requires the 401 response to include a WWW-Authenticate header pointing back to the protected resource metadata endpoint. This is how MCP clients know where to re-authenticate. The challenge block in Ktor’s JWT configuration is the right place for this.

Configuration

The whole setup is driven by environment variables through Ktor’s HOCON configuration:

keycloak {
    internal-url = ${KEYCLOAK_INTERNAL_URL}
    external-url = ${KEYCLOAK_EXTERNAL_URL}
    realm = ${KEYCLOAK_REALM}
    client-id = ${KEYCLOAK_CLIENT_ID}
    client-secret = ${KEYCLOAK_CLIENT_SECRET}
}

server {
    external-url = ${SERVER_EXTERNAL_URL}
}

Six environment variables and you have a fully authenticated MCP server. The internal/external URL split is the only non-obvious part, and it only matters if you are running in Kubernetes. For a single-host deployment, set both to the same value.

Summary

Wiring MCP auth to Keycloak requires three things: well-known metadata endpoints that point to your Keycloak realm, dynamic client registration with the trusted-hosts policy removed, and JWT validation that handles Keycloak’s claim conventions. The stateless Streamable HTTP transport keeps the server side simple — no sessions, no WebSocket state, just validate the token and handle the request.