Guide · Auth & Identity

MCP Tools for Keycloak — realm config, role claim mappers, offline tokens, multi-tenant, PKCE

Three Keycloak behaviours catch developers building MCP auth integrations: realm and client roles are absent from access tokens by default — without explicit role mapper configuration in the client's Mappers tab, the realm_access and resource_access claims are omitted, and MCP tool handlers that check token.realm_access?.roles.includes('admin') will silently always evaluate to false; JWKS keys rotate when you regenerate realm keys in the admin console, which is an on-demand operation not a scheduled one — any external service caching Keycloak's public keys indefinitely will fail to verify tokens signed with the new key until its cache is cleared, and the JWKS URL (/realms/{realm}/.well-known/openid-configurationjwks_uri) must be fetched with a TTL-bounded cache that respects the Cache-Control header; and offline tokens are refresh tokens that never expire by default — they survive Keycloak server restarts and session revocations, meaning a leaked offline token grants persistent access until explicitly revoked via the admin API or user account console.

TL;DR

JWKS: GET /realms/{realm}/.well-known/openid-configurationjwks_uri. Add role mapper: Client → Mappers → "realm roles" (User Realm Role type) with Token Claim Name realm_access.roles. Service account M2M: Client → Settings → enable "Service account roles" + "Client authentication". Offline token: add offline_access scope; revoke via DELETE /admin/realms/{realm}/sessions/{sessionId}.

Role claim mappers — roles absent from JWT by default

Keycloak stores realm roles and client roles in its database, but does not automatically include them in the access token JWT payload. You must add a mapper on the client (or client scope) to populate role claims. This is one of the most common Keycloak integration mistakes: the roles exist in the admin console, the user is assigned to them, but the token contains no role information.

// Step 1: Add mappers in Keycloak admin console
// Client → Mappers → Add mapper → "User Realm Role"
// Configuration:
//   Name: realm-roles
//   Mapper type: User Realm Role
//   Token Claim Name: realm_access.roles
//   Claim JSON Type: String
//   Add to access token: ON
//   Add to ID token: ON (optional)
//   Add to userinfo: OFF (unless needed)

// Client → Mappers → Add mapper → "User Client Role"
// Configuration:
//   Name: client-roles
//   Mapper type: User Client Role
//   Client ID: your-client-id (the specific client)
//   Token Claim Name: resource_access.your-client-id.roles
//   Claim JSON Type: String
//   Add to access token: ON

// Step 2: Verify the mapped claims in your MCP tool handler
interface KeycloakTokenClaims {
  sub: string;            // Keycloak user UUID
  preferred_username: string;
  email?: string;
  realm_access?: {
    roles: string[];      // Realm-level roles (e.g., 'admin', 'user')
  };
  resource_access?: {
    [clientId: string]: {
      roles: string[];    // Client-level roles
    };
  };
}

function hasRealmRole(claims: KeycloakTokenClaims, role: string): boolean {
  return claims.realm_access?.roles?.includes(role) ?? false;
}

function hasClientRole(claims: KeycloakTokenClaims, clientId: string, role: string): boolean {
  return claims.resource_access?.[clientId]?.roles?.includes(role) ?? false;
}

// Verify and extract roles from a Keycloak access token
import { createRemoteJWKSet, jwtVerify } from 'jose';

const KEYCLOAK_URL = process.env.KEYCLOAK_URL!;
const REALM = process.env.KEYCLOAK_REALM!;

const keycloakJWKS = createRemoteJWKSet(
  new URL(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/certs`),
  { cacheMaxAge: 60 * 60 * 1000 } // 1 hour cache — refresh on key rotation via kid mismatch
);

async function verifyKeycloakToken(token: string): Promise<KeycloakTokenClaims> {
  const { payload } = await jwtVerify(token, keycloakJWKS, {
    issuer: `${KEYCLOAK_URL}/realms/${REALM}`,
    // audience is the client_id for access tokens (or omit if using bearer-only)
  });
  return payload as unknown as KeycloakTokenClaims;
}

// MCP tool handler: check role before executing
async function handleAdminTool(bearerToken: string, params: Record<string, unknown>) {
  const claims = await verifyKeycloakToken(bearerToken.replace(/^Bearer /, ''));
  if (!hasRealmRole(claims, 'admin')) {
    throw new Error('Insufficient role: admin required');
  }
  return performAdminAction(claims.sub, params);
}

If you use client scopes (shared across multiple clients), role mappers added to a client scope propagate to all clients that include that scope. This is the recommended approach for platform-wide roles — add the realm role mapper to the roles client scope rather than per-client, so every client receives role claims without individual configuration.

JWKS key rotation — on-demand, not scheduled

Keycloak's signing key rotation is triggered manually by an admin (Realm Settings → Keys → Providers → Action → Rotate) or automatically by the RSA Generated key provider's priority configuration. Unlike cloud-managed identity providers that rotate keys on a predictable schedule, Keycloak key rotation is event-driven and can happen at any time when an admin takes action.

// Keycloak JWKS endpoint — always discover from OpenID configuration, never hardcode
// The JWKS endpoint URL is stable, but the key IDs (kid) change on rotation

const KEYCLOAK_ISSUER = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`;

// Discover JWKS URI from OpenID configuration
let jwksUri: string | null = null;
async function getJWKSUri(): Promise<string> {
  if (jwksUri) return jwksUri;
  const response = await fetch(`${KEYCLOAK_ISSUER}/.well-known/openid-configuration`);
  const config = await response.json();
  jwksUri = config.jwks_uri;
  // jwks_uri is typically: ${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/certs
  return jwksUri!;
}

// Build JWKS key set with automatic refresh on unknown kid
async function buildJWKS() {
  const uri = await getJWKSUri();
  return createRemoteJWKSet(new URL(uri), {
    cacheMaxAge: 60 * 60 * 1000, // 1-hour cache
    // JOSE's createRemoteJWKSet automatically re-fetches if a received token's
    // kid is not in the current cached key set — handles on-demand rotation
  });
}

// Handle key rotation gracefully — retry once on signature failure
async function verifyWithRotationHandling(
  token: string,
  jwks: Awaited<ReturnType<typeof buildJWKS>>
) {
  try {
    return await jwtVerify(token, jwks, { issuer: KEYCLOAK_ISSUER });
  } catch (err) {
    if (String(err).includes('JWSSignatureVerificationFailed') ||
        String(err).includes('JWKSNoMatchingKey')) {
      // Key may have been rotated — force a JWKS refresh by clearing the
      // in-process cache and retrying once
      // Note: createRemoteJWKSet handles this automatically via kid lookup;
      // this explicit retry is only needed if you're managing your own key cache
      console.warn('Keycloak JWKS cache miss — key may have rotated, retrying');
      throw new Error(`Token verification failed after key rotation: ${err}`);
    }
    throw err;
  }
}

// Configuration: set a reasonable key rotation period in Keycloak
// Realm Settings → Keys → RSA Generated → Action → Edit
// "Active" key period: 90 days; "Alive" period (retired key validity): 30 days
// The "Alive" period allows tokens signed with the old key to remain valid
// during the overlap window — set it >= your token TTL (access token default: 5 min)

When Keycloak rotates a key, it keeps the previous key active for a configurable "alive" duration (default: 30 days). Tokens signed before the rotation remain verifiable because the old key stays in the JWKS response during this window. After the alive period expires, the old key is removed, and tokens signed with it will fail verification — but those tokens will have long since expired (access tokens typically expire in minutes, ID tokens in minutes to hours).

Offline tokens — persistent access without session dependency

Offline tokens are a specialised refresh token type that grant persistent access without requiring an active Keycloak session. They are designed for scenarios where a background service needs to act on behalf of a user without user interaction. In MCP server contexts, they enable long-running agent workflows to continue operating after the user's browser session closes.

import Keycloak from 'keycloak-connect';

// Request an offline token by including 'offline_access' scope
// This requires the 'offline_access' scope to be enabled on the realm and client

// Authorization URL with offline_access scope
function buildOfflineTokenAuthUrl(
  keycloakUrl: string,
  realm: string,
  clientId: string,
  redirectUri: string,
  state: string
): string {
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    response_type: 'code',
    scope: 'openid offline_access', // 'offline_access' triggers offline token issuance
    state,
    // PKCE required for public clients
    code_challenge_method: 'S256',
    code_challenge: 'COMPUTE_FROM_CODE_VERIFIER',
  });
  return `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?${params}`;
}

// Use the offline token (refresh token) to obtain a fresh access token
async function refreshWithOfflineToken(
  keycloakUrl: string,
  realm: string,
  clientId: string,
  offlineToken: string
): Promise<{ accessToken: string; newOfflineToken: string }> {
  const response = await fetch(
    `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: clientId,
        refresh_token: offlineToken, // the offline token acts as a persistent refresh token
      }),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    // 'invalid_grant': offline token revoked or user account deleted
    throw new Error(`Offline token refresh failed: ${error.error} — ${error.error_description}`);
  }

  const tokens = await response.json();
  return {
    accessToken: tokens.access_token,
    newOfflineToken: tokens.refresh_token, // Keycloak rotates offline tokens on use
  };
}

// Revoke an offline token explicitly (critical for deprovisioning)
async function revokeOfflineToken(
  keycloakUrl: string,
  realm: string,
  clientId: string,
  offlineToken: string
): Promise<void> {
  await fetch(
    `${keycloakUrl}/realms/${realm}/protocol/openid-connect/revoke`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: clientId,
        token: offlineToken,
        token_type_hint: 'refresh_token',
      }),
    }
  );
}

// SECURITY: Offline tokens do not expire by default (SSO session max is irrelevant for offline)
// Configure maximum offline token lifespan: Realm Settings → Tokens → Offline Session Max
// Rotate offline tokens on use: Keycloak rotates them automatically when 'Revoke Refresh Token' is ON
// Enable: Realm Settings → Tokens → Revoke Refresh Token → ON

Because offline tokens do not expire with the SSO session, they must be stored securely — treat them with the same sensitivity as passwords. If a user's account is deleted in Keycloak, their offline tokens become invalid immediately (the refresh call returns invalid_grant). If a user changes their password, offline tokens are revoked only if "Revoke Offline Sessions on Password Change" is enabled in realm settings — this is off by default.

Service accounts for M2M (client credentials flow)

For MCP servers that need to act autonomously (not on behalf of a user), the client credentials flow uses a Keycloak "service account" — a synthetic user associated with the client. Service accounts must be explicitly enabled on the client, and roles must be assigned to the service account user separately from regular user role assignments.

// Setup in Keycloak admin console:
// 1. Client → Settings → Client authentication: ON
// 2. Client → Settings → Authentication flow: check "Service accounts roles"
// 3. Client → Service account roles → Assign realm or client roles to the service account

// Client credentials token request
async function getClientCredentialsToken(
  keycloakUrl: string,
  realm: string,
  clientId: string,
  clientSecret: string
): Promise<string> {
  const response = await fetch(
    `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: clientId,
        client_secret: clientSecret,
        // Optional: scope specific roles
        // scope: 'openid mcp:read mcp:write',
      }),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Client credentials failed: ${error.error}`);
  }

  const tokens = await response.json();
  return tokens.access_token;
}

// Cache the client credentials token until near expiry
let cachedServiceToken: { token: string; expiresAt: number } | null = null;

async function getServiceToken(
  keycloakUrl: string,
  realm: string,
  clientId: string,
  clientSecret: string
): Promise<string> {
  const now = Date.now();
  if (cachedServiceToken && now < cachedServiceToken.expiresAt - 30_000) {
    return cachedServiceToken.token;
  }

  const token = await getClientCredentialsToken(keycloakUrl, realm, clientId, clientSecret);
  // Decode expiry from JWT (access_token default: 5 minutes in Keycloak)
  const payload = JSON.parse(atob(token.split('.')[1]));
  cachedServiceToken = { token, expiresAt: payload.exp * 1000 };
  return token;
}

// The service account user is visible in admin console under Users
// Username: "service-account-{client-id}"
// Assign roles to this user just like a regular user
// Realm roles assigned to service account appear in realm_access.roles claim
// (requires the realm-roles mapper, same as for user tokens)

The service account's roles must be assigned via the admin console's "Service Account Roles" tab on the client, or via the admin API. Roles assigned to the service account user directly in the Users list also work (the service account user is a regular user in the realm). However, roles assigned via group membership do not propagate to service accounts — service accounts are not group members.

Multi-tenant patterns — separate realms vs single realm with groups

Two common Keycloak multi-tenancy approaches have different trade-offs for MCP server deployments. Separate realms provide strong isolation but complicate JWKS management; a single realm with organisations (Keycloak 24+) or group-based tenant segregation is simpler but requires careful data isolation.

// Pattern A: Separate realms (strong isolation)
// Each tenant has its own realm: /realms/tenant-acme, /realms/tenant-globocorp
// MCP server must determine which realm to validate against from the token's 'iss' claim

async function verifyMultiRealmToken(token: string): Promise<KeycloakTokenClaims> {
  // Decode without verification to read the issuer
  const [, payloadB64] = token.split('.');
  const payload = JSON.parse(atob(payloadB64));
  const issuer: string = payload.iss; // e.g., "https://kc.example.com/realms/tenant-acme"

  // Validate the issuer is one of our known realms
  const allowedIssuers = (process.env.KEYCLOAK_ALLOWED_ISSUERS ?? '').split(',');
  if (!allowedIssuers.some(i => issuer.startsWith(i.trim()))) {
    throw new Error(`Untrusted issuer: ${issuer}`);
  }

  // Build or retrieve a JWKS set for this specific realm
  const jwks = await getOrBuildJWKSForIssuer(issuer);
  const { payload: claims } = await jwtVerify(token, jwks, { issuer });
  return claims as unknown as KeycloakTokenClaims;
}

// Cache JWKS per realm issuer
const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

async function getOrBuildJWKSForIssuer(issuer: string) {
  if (!jwksByIssuer.has(issuer)) {
    const configResponse = await fetch(`${issuer}/.well-known/openid-configuration`);
    const config = await configResponse.json();
    jwksByIssuer.set(
      issuer,
      createRemoteJWKSet(new URL(config.jwks_uri), { cacheMaxAge: 60 * 60 * 1000 })
    );
  }
  return jwksByIssuer.get(issuer)!;
}

// Pattern B: Single realm with Keycloak Organizations (Keycloak 24+)
// or Groups for tenant segregation (Keycloak < 24)
// Add a group membership mapper to include tenant info in the token:
// Mapper type: Group Membership
// Token Claim Name: groups
// Full group path: OFF (returns just the group name, not /tenant/subgroup)

function extractTenantFromGroups(groups: string[]): string | null {
  // Convention: groups prefixed with "tenant-" identify the tenant
  const tenantGroup = groups.find(g => g.startsWith('tenant-'));
  return tenantGroup ? tenantGroup.slice('tenant-'.length) : null;
}

Cross-realm token exchange in Keycloak (Token Exchange feature) allows a token from one realm to be exchanged for a token in another realm, enabling cross-tenant federation scenarios. However, Token Exchange is a technology preview feature in Keycloak and must be enabled explicitly in realm settings. Do not rely on it for production multi-tenant isolation — prefer separate clients with explicit trust relationships instead.

Health probe — Keycloak realm reachability

Keycloak's health endpoint provides cluster-level health information. For MCP server health checks, the most relevant probe is whether the specific realm's token endpoint is reachable and responding, since realm-level operations (user federation sync failures, key provider errors) can affect a single realm without taking down the entire Keycloak instance.

const KEYCLOAK_URL = process.env.KEYCLOAK_URL!;
const REALM = process.env.KEYCLOAK_REALM!;

async function checkKeycloakHealth(): Promise<{
  healthy: boolean;
  latencyMs?: number;
  error?: string;
}> {
  const start = Date.now();
  try {
    // Keycloak 17+ built-in health endpoint (Quarkus distribution)
    const healthRes = await fetch(`${KEYCLOAK_URL}/health/ready`, {
      signal: AbortSignal.timeout(5000),
    });
    if (!healthRes.ok) {
      return { healthy: false, error: `Health endpoint: HTTP ${healthRes.status}` };
    }

    // Realm-specific check: verify OIDC configuration is accessible
    const realmRes = await fetch(
      `${KEYCLOAK_URL}/realms/${REALM}/.well-known/openid-configuration`,
      { signal: AbortSignal.timeout(5000) }
    );
    if (!realmRes.ok) {
      return { healthy: false, error: `Realm config: HTTP ${realmRes.status}` };
    }

    return { healthy: true, latencyMs: Date.now() - start };
  } catch (err) {
    return { healthy: false, error: String(err) };
  }
}

// Startup verification
async function startMcpServer() {
  const health = await checkKeycloakHealth();
  if (!health.healthy) {
    console.error('Keycloak health check failed:', health.error);
    process.exit(1);
  }
  console.log(`Keycloak realm "${REALM}" reachable (${health.latencyMs}ms)`);
}

AliveMCP monitors Keycloak-integrated MCP servers by probing the realm's OpenID configuration endpoint (which reflects key rotation and realm availability) and watching for authentication error patterns in the MCP server's response codes — a spike in 401s that doesn't correlate with traffic changes typically indicates a Keycloak key rotation or session store issue.

Related guides