Skip to main content

Keycloak Integration

Integrate Keycloak as the identity provider for your marketplace application. This guide covers versions 24 through 26, highlighting the behavioral differences between each release.

Prerequisites

  • Keycloak Server: Version 24.0 or higher
  • Realm: A dedicated realm for marketplace users
  • Confidential Client with:
    • Client ID and Client Secret
    • Direct Access Grants enabled (for ROPC password grant)
    • Valid Redirect URIs configured
    • Service Account Roles: manage-users, view-users on the realm-management client
  • Admin Credentials: For the Admin REST API (user provisioning)

Keycloak Configuration

  1. Create a Realm (or use existing):

    Realm Name: gw-marketplace
  2. Create a Confidential Client:

    Client ID: gw-app-client
    Client Protocol: openid-connect
    Access Type: confidential
    Direct Access Grants: Enabled
    Service Accounts: Enabled
  3. Add Protocol Mappers (for custom claims in tokens):

    • gwUserId -- User Attribute mapper, claim name: gwUserId
    • orgId -- User Attribute mapper, claim name: orgId
    • gwApplicationId -- User Attribute mapper, claim name: gwApplicationId
  4. Create Realm Roles:

    • gw-user -- Assigned to all marketplace users
    • org-admin -- Assigned to organization administrators

Environment Variables

# Keycloak Server
KEYCLOAK_URL=https://keycloak.your-domain.com

# Realm Configuration
KEYCLOAK_REALM=gw-marketplace

# Client Credentials (for token exchange)
KEYCLOAK_CLIENT_ID=gw-app-client
KEYCLOAK_CLIENT_SECRET=your-client-secret

# Admin Credentials (for user provisioning via Admin REST API)
KEYCLOAK_ADMIN_USER=admin
KEYCLOAK_ADMIN_PASSWORD=your-admin-password

# Marketplace Configuration
MARKETPLACE_APP_ID=your-registered-app-id

Exchange Flow

sequenceDiagram
participant SDK as Provider SDK
participant Backend as Your Backend
participant KC as Keycloak

SDK->>Backend: POST /auth/exchange (Bearer marketplace-jwt)
Backend->>Backend: Validate JWT (signature, iss, applicationId, exp)
Backend->>KC: POST /realms/master/.../token (admin credentials)
KC-->>Backend: Admin access token
Backend->>KC: GET /admin/realms/{realm}/users?username=gw-{userId}
alt User exists
KC-->>Backend: User record
Backend->>KC: PUT /admin/realms/{realm}/users/{id} (update attributes)
else User not found
Backend->>KC: POST /admin/realms/{realm}/users (create)
KC-->>Backend: 201 + Location header
Backend->>KC: POST .../role-mappings/realm (assign roles)
end
Backend->>KC: POST /realms/{realm}/.../token (ROPC grant)
KC-->>Backend: access_token + refresh_token
Backend-->>SDK: Tokens + user info

Backend Implementation

import { createHash } from 'crypto';

// Deterministic password derivation -- no password storage needed
function derivedPassword(userId: string, clientSecret: string): string {
return createHash('sha256')
.update(`${userId}${clientSecret}`)
.digest('hex');
}

// Admin token acquisition (cached, refreshed before expiry)
async function getAdminToken(): Promise<string> {
const params = new URLSearchParams({
grant_type: 'password',
client_id: 'admin-cli',
username: process.env.KEYCLOAK_ADMIN_USER!,
password: process.env.KEYCLOAK_ADMIN_PASSWORD!,
});

const res = await fetch(
`${process.env.KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
},
);

if (!res.ok) throw new Error(`Admin token failed: ${res.status}`);
const data = await res.json();
return data.access_token;
}

// Find user by deterministic username
async function findUser(userId: string, adminToken: string) {
const username = `gw-${userId}`;
const res = await fetch(
`${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users?username=${encodeURIComponent(username)}&exact=true`,
{ headers: { Authorization: `Bearer ${adminToken}` } },
);

if (!res.ok) throw new Error(`User search failed: ${res.status}`);
const users = await res.json();
return users.length > 0 ? users[0] : null;
}

// Create user with credentials and custom attributes
async function createUser(claims: any, adminToken: string) {
const username = `gw-${claims.userId}`;
const password = derivedPassword(claims.userId, process.env.KEYCLOAK_CLIENT_SECRET!);

const res = await fetch(
`${process.env.KEYCLOAK_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
email: claims.email ?? `${claims.userId}@gw-marketplace.local`,
enabled: true,
emailVerified: true,
attributes: {
gwUserId: [claims.userId],
orgId: [claims.orgId],
gwApplicationId: [claims.applicationId],
},
credentials: [{ type: 'password', value: password, temporary: false }],
}),
},
);

if (!res.ok) throw new Error(`Create user failed: ${res.status}`);

// Extract user ID from Location header
const location = res.headers.get('location');
return location ? location.split('/').pop()! : null;
}

// Obtain tokens via ROPC grant
async function getUserTokens(username: string, password: string) {
const params = new URLSearchParams({
grant_type: 'password',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
username,
password,
scope: 'openid',
});

const res = await fetch(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
},
);

if (!res.ok) throw new Error(`Token grant failed: ${res.status}`);
return res.json();
}

Version Differences (v24 / v25 / v26)

Keycloak introduced breaking changes across versions 24 through 26. The following table summarizes what differs:

Behaviorv24v25v26
requiredActions quirkCredentials with temporary:false may still set UPDATE_PASSWORDFixed -- no workaround neededFixed
Attribute updatesGET+PUT merge (partial merge preserves existing attrs)GET+PUT merge (same as v24)Full replacement -- no merge, no GET needed
session_state in tokensPresent as UUIDPresent as UUIDRemoved -- use sid (base64, 24 chars) instead
Persistent sessionsNot enabled by defaultEnabled by defaultEnabled by default
Username mutabilityRead-only after creationRead-only after creationRead-only after creation

v24: requiredActions Workaround

Keycloak 24 has a known quirk where creating a user with temporary: false on the credential may still set requiredActions: ["UPDATE_PASSWORD"]. This blocks the ROPC password grant. You must clear required actions after user creation:

// KC 24 only: clear spurious requiredActions
async function ensureLoginReady(userId: string, adminToken: string) {
const url = `${KEYCLOAK_URL}/admin/realms/${REALM}/users/${userId}`;
const getRes = await fetch(url, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const user = await getRes.json();

if (user.requiredActions?.length > 0) {
await fetch(url, {
method: 'PUT',
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...user, requiredActions: [], emailVerified: true, enabled: true }),
});
}
}

v25: Persistent Sessions

KC 25 enables persistent sessions by default. User sessions survive server restarts without external session stores. No code change is required, but be aware that refresh tokens remain valid across restarts unless explicitly revoked.

v26: Attribute Full Replacement

KC 26 changed attribute handling from partial merge to full replacement. When you PUT attributes, the provided set becomes the complete attribute set -- any attributes not included are removed.

// KC 24/25: GET+PUT merge (preserves other attributes)
async function updateAttributesV24(userId: string, attrs: Record<string, string>) {
const user = await getUser(userId);
const merged = { ...(user.attributes ?? {}) };
for (const [k, v] of Object.entries(attrs)) merged[k] = [v];
await putUser(userId, { ...user, attributes: merged });
}

// KC 26: Full replacement (no GET needed, but all attrs must be provided)
async function updateAttributesV26(userId: string, attrs: Record<string, string>) {
const attributes: Record<string, string[]> = {};
for (const [k, v] of Object.entries(attrs)) attributes[k] = [v];
await putUser(userId, { attributes });
}

v26: Session ID Format Change

KC 26 removes session_state from access tokens entirely. If your code reads session_state, migrate to the sid claim which is now a base64-encoded string (24 characters) rather than a UUID.

// KC 24/25
const sessionId = payload.session_state; // UUID: "d4f5a6b7-1234-5678-9abc-def012345678"

// KC 26
const sessionId = payload.sid; // Base64: "YWJjZGVmZ2hpamtsbW5v"

Token Structure

Keycloak access tokens contain roles under realm_access.roles:

{
"exp": 1714000000,
"iss": "https://keycloak.example.com/realms/gw-marketplace",
"preferred_username": "gw-user-abc123",
"email": "user@example.com",
"realm_access": {
"roles": ["gw-user", "org-admin"]
},
"gwUserId": "user-abc123",
"orgId": "org-456",
"gwApplicationId": "app-789"
}

Session Revocation

Revoke sessions on the OIDC logout endpoint using the refresh token:

async function revokeSession(refreshToken: string) {
const params = new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
refresh_token: refreshToken,
});

await fetch(
`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
},
);
}

Admin REST API Reference

Base URL: {KEYCLOAK_URL}/admin/realms/{REALM}

OperationMethodEndpoint
Search UsersGET/users?username={username}&exact=true
Create UserPOST/users
Get UserGET/users/{id}
Update UserPUT/users/{id}
Reset PasswordPUT/users/{id}/reset-password
Get RoleGET/roles/{roleName}
Assign RolesPOST/users/{id}/role-mappings/realm
Remove RolesDELETE/users/{id}/role-mappings/realm

Common Gotchas

  1. 409 Conflict on user creation: Another user with the same email already exists. Look up by email and adopt the existing user instead of failing.

  2. "Action required" on token grant: KC 24 sets requiredActions even with temporary: false. Call ensureLoginReady() before attempting the password grant.

  3. Attributes silently lost on KC 26: Because KC 26 uses full replacement, a PUT that only includes your GW attributes will remove any attributes set by other systems. Include the complete attribute set.

  4. Admin token expiration: Admin tokens have a short lifetime (default 60s). Cache the token and refresh it proactively -- check expiry before each admin API call.

  5. CORS errors on frontend: Ensure your application's origin is listed in the client's "Web Origins" setting. Use + to automatically allow all valid redirect URIs.

  6. Client must be confidential: Public clients cannot use client secrets for the ROPC grant. Set Access Type to "confidential" in the client settings.

Additional Resources