MCP Auth with Keycloak and Ktor
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:
- Protected Resource Metadata (RFC 9728): The client hits
/.well-known/oauth-protected-resourceon your server to find out which authorization server to talk to. - Authorization Server Metadata (RFC 8414): The client then fetches
/.well-known/oauth-authorization-serverfrom 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_endpointpoints to Keycloak’s OpenID Connect dynamic client registration endpoint. MCP clients use this to register themselves as OAuth clients.code_challenge_methods_supportedadvertises onlyS256. PKCE is required by the MCP spec, and there is no reason to support theplainmethod.token_endpoint_auth_methods_supportedincludesnonebecause public MCP clients (like Claude Desktop) cannot hold a client secret.- The scopes list includes
offline_accessso 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
JWTPrincipalis extracted from each request independently and injected into a newServerinstance. 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 theissclaim 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.