LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Keycloak in Production: Realm Design, Federation, Kubernetes HA, and the Operational Reality of Running Your Own Identity Provider

keycloakidentityoidcoauth2kubernetessecurityssoldapdevopsplatform-engineering
Contents

Running your own identity provider is one of those decisions that looks straightforward until the first time a misconfigured redirect URI causes an OAuth2 callback loop at 2 AM, or your LDAP sync job silently stops working and nobody notices until payroll can’t log in. Keycloak is remarkably capable — it handles OIDC, OAuth2, SAML 2.0, user federation, social login, MFA, passkeys, and multi-tenancy Organizations all in one open-source package backed by Red Hat. That capability comes with a configuration surface area that can swallow teams whole.

This guide is the field manual you wish you’d had before standing up Keycloak at scale. We’ll cover realm architecture, the client scopes and protocol mapper model, LDAP federation, authentication flows including WebAuthn passkeys, Kubernetes HA deployment with the official Operator, theme customization, production operations, and — most importantly — the pitfalls that aren’t in the official docs.

What Keycloak Is (and Isn’t)

Keycloak is an open-source Identity and Access Management (IAM) server. It speaks OIDC/OAuth2, SAML 2.0, and various legacy protocols. It ships with an admin console, a REST/Admin API, a user account self-service console, and a rich SPI (Service Provider Interface) model that lets you extend almost everything.

Current version: As of May 2026, the active release line is Keycloak 26.6.x, with 26.6.2 released May 19, 2026. The project follows a cadence of four minor releases per year within a major release cycle. Keycloak 26 introduced Organizations (multi-tenancy) as GA, DPoP as fully supported, FAPI 2 Final support, and Zero-Downtime patch releases via the Operator. Keycloak 26.4 promoted passkeys from preview to supported. Keycloak 26.6 added JWT Authorization Grant (RFC 7523), Federated Client Authentication, and Workflows for realm administration.

Quarkus-based since 17.x: The WildFly distribution was retired. The current distribution runs on Quarkus 3.31+ (Keycloak 26.6). This matters because the build model changed: Keycloak now has a build step that compiles configuration into the server binary, and a start step that runs it. The --optimized flag tells start to skip the build check and run from the pre-compiled artifact — critical for container image performance.

Red Hat’s downstream: Red Hat build of Keycloak (RHBK) is the supported enterprise product. It ships with the same major version numbers as community Keycloak but on a defined lifecycle with longer support windows, and appears in the OpenShift OperatorHub as a first-class citizen.

Docker image: quay.io/keycloak/keycloak:<version> (community) or registry.redhat.io/rhbk/keycloak-rhel9:<version> (RHBK).


The Build Step Model

Before diving into deployment, understand the two-phase execution model:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Phase 1: build — bakes config into the binary (done at image build time)
kc.sh build \
  --db=postgres \
  --features=token-exchange,organization \
  --metrics-enabled=true \
  --health-enabled=true

# Phase 2: start — uses the pre-built binary
kc.sh start --optimized \
  --hostname=https://auth.example.com \
  --db-url=jdbc:postgresql://pg:5432/keycloak \
  --db-username=keycloak \
  --db-password="${KC_DB_PASSWORD}"

Build-time options (database vendor, feature flags, metrics/health) get compiled in. Runtime options (hostname, credentials, proxy settings) are passed at startup. Mixing them up causes confusing errors. The start-dev command skips the build step and uses insecure defaults — never use start-dev in production.


Realm Design

The realm is Keycloak’s fundamental isolation boundary. Every user, client, role, and identity provider lives inside a realm. Realms are completely isolated — a user in realm A cannot authenticate to a client in realm B without explicit federation.

The Master Realm: Admin Only

The master realm exists to administer other realms. The admin superuser account lives here. Never create application clients in the master realm. The master realm admin has full access to the entire Keycloak installation; an OIDC client in master inherits that blast radius. All application workloads belong in dedicated realms.

Realm Strategies

Per-environment realms (dev, staging, prod) in the same Keycloak instance give you clean separation of users and configs between environments. This is common for smaller organizations. The tradeoff is that realm configurations must be kept in sync across environments, which pushes you toward GitOps.

Per-application realms (one realm per business domain or product) give the strongest isolation and let separate teams manage their own clients and users. It scales cleanly to a platform model where each team owns their realm. The downsides: users don’t get SSO across realms, realm count can grow large, and management overhead multiplies.

Single realm with many clients works well when all your applications share the same user population and you want true SSO across them. Protocol mappers and client-specific scopes let you customize token content per-application. With Keycloak 26’s Organizations feature now GA, you can implement B2B multi-tenancy (multiple companies/tenants) within a single realm — each Organization gets its own identity providers, invitation flows, and organization-level roles without needing separate realms.

Rule of thumb: start with per-environment realms within a single Keycloak installation. Split into per-application realms only when a team genuinely needs isolation from other teams’ users or clients.

Realm Settings That Matter

Token lifetimes (Realm Settings → Tokens):

  • Access Token Lifespan: default 5 minutes — keep it short; refresh tokens do the heavy lifting
  • Refresh Token Lifespan: controlled by SSO session max (default 30 days) and SSO session idle (default 30 min)
  • Do not conflate “access token” lifetime with “user session” lifetime

Brute Force Protection (Realm Settings → Security Defenses): Enable this. Keycloak 26.6 adds separate brute force counters for passwords and OTP codes. Configure:

  • Max login failures: 5
  • Wait increment: 30 seconds
  • Max wait: 15 minutes
  • Failure reset time: 12 hours

SSL Required: Set to all requests in production. external requests (the default) allows HTTP on private IP ranges — acceptable only for internal dev environments.

Password Policy: At minimum enforce: minimum length (12+), not username, not email, digits required, special characters required, and a password history of 5.

Realm Import/Export for GitOps

Realm export is the foundation of infrastructure-as-code for Keycloak. Export produces JSON that can be committed to Git and applied via CI/CD.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Export a single realm to a file (run against a running Keycloak instance)
kc.sh export --realm myrealm --file /tmp/myrealm-export.json

# Export to a directory (splits users into separate files for large realms)
kc.sh export --realm myrealm --dir /tmp/realm-export/ --users different_files

# Import at startup (for GitOps/operator pattern)
kc.sh start --import-realm

# Import with no overwrite (safe for CI idempotency)
kc.sh import --dir /tmp/realm-export/ --override false

The exported JSON supports environment variable substitution with ${ENV_VAR} syntax — use this to parameterize secrets and hostnames across environments:

1
2
3
4
5
6
7
{
  "realm": "${REALM_NAME}",
  "sslRequired": "all",
  "smtpServer": {
    "password": "${SMTP_PASSWORD}"
  }
}

Important: CLI exports include hashed passwords and LDAP bind credentials. Treat export files as secrets. Admin Console exports mask credentials with asterisks — they are useful for diffing configuration, not for backup or server transfer.


Clients: The Authorization Surface Area

A client represents an application that uses Keycloak for authentication. Misconfigured clients are the most common source of security incidents in Keycloak deployments.

Confidential vs Public Clients

Confidential clients have a server-side component that can hold a secret. They authenticate to Keycloak’s token endpoint using client_secret_basic (HTTP Basic Auth with client ID and secret), client_secret_jwt (a JWT signed with the shared secret), or private_key_jwt (a JWT signed with an asymmetric private key). Use confidential clients for backend APIs, server-rendered apps, and service accounts.

Public clients cannot hold a secret (SPAs, mobile apps). They rely on PKCE (Proof Key for Code Exchange) to prevent authorization code interception. Always enable PKCE for public clients:

  • Set Proof Key for Code Exchange Code Challenge Method to S256
  • Never rely on plain PKCE — it provides no real security benefit

Redirect URI Wildcard Gotcha

This is the most common Keycloak misconfiguration leading to open redirector vulnerabilities. Keycloak allows wildcard redirect URIs: https://app.example.com/*. The problem: https://app.example.com.evil.com/callback does NOT match this pattern, but https://app.example.com/anything/at/all does — including paths that could be used to exfiltrate authorization codes.

Production rule: use exact redirect URIs wherever possible. If you need multiple, list them explicitly. Never use * as the entire URI. The pattern https://app.example.com/* is acceptable; * alone as a valid redirect URI is an immediate CVE-class issue.

Service Accounts (Client Credentials Grant)

Enable the Service accounts roles toggle on a confidential client to enable the OAuth2 client credentials grant. The service account user gets a system-generated username (service-account-<clientId>) and can be assigned realm roles and client roles. Use this for machine-to-machine authentication:

1
2
3
4
5
curl -s -X POST "https://auth.example.com/realms/myrealm/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=my-service" \
  -d "client_secret=${CLIENT_SECRET}"

Client Roles vs Realm Roles

Realm roles are available across all clients in a realm. Use them for coarse-grained permissions (admin, user, moderator) that span multiple applications.

Client roles are scoped to a specific client. Use them for application-specific permissions (e.g., my-app:read, my-app:write). They appear in the resource_access claim in tokens.

Both are included in the JWT via the built-in roles scope, but the mapper behavior differs. The realm_access.roles claim contains realm roles; resource_access.<clientId>.roles contains client roles.


Client Scopes and Protocol Mappers

This is where Keycloak’s token content model lives, and it is — frankly — over-engineered compared to simpler OIDC providers. Spend time here; it pays dividends.

The Scope Model

A client scope is a named container for protocol mappers and role scope mappings. Scopes can be assigned to clients as default (always included) or optional (included only when explicitly requested via the scope parameter in the authorization request).

Built-in scopes you will encounter:

  • openid — always required for OIDC; triggers ID token issuance
  • profile — maps name, given_name, family_name, preferred_username, picture, website, gender, birthdate, locale, zoneinfo, updated_at
  • email — maps email and email_verified
  • roles — maps realm and client roles into realm_access and resource_access claims
  • address — maps the OpenID Connect address claim
  • phone — maps phone_number and phone_number_verified
  • offline_access — enables refresh tokens that survive server restarts (offline sessions stored in DB)
  • microprofile-jwt — adds groups and upn claims for Jakarta EE/MicroProfile compatibility

Scope parameter in authorization requests: The scope parameter in the OAuth2 authorization URL controls which optional scopes are included. scope=openid email profile groups would include the groups scope (if configured as optional).

Protocol Mappers

Protocol mappers are the functions that produce individual claims in tokens. Each scope contains zero or more mappers. Common mapper types:

User Attribute Mapper — maps a custom user attribute to a token claim:

Mapper Type: User Attribute
User Attribute: department
Token Claim Name: department
Claim JSON Type: String
Add to ID token: on
Add to access token: on
Add to userinfo: on

Group Membership Mapper — adds user group memberships as a claim:

Mapper Type: Group Membership
Token Claim Name: groups
Full group path: off  (produces "engineering", not "/org/engineering")

Audience Mapper — adds a service’s client ID to the aud claim of access tokens. This is how backend APIs validate that a token was issued for them:

Mapper Type: Audience
Included Client Audience: my-api-service
Add to access token: on

Hardcoded Claim Mapper — injects a static value into every token:

Mapper Type: Hardcoded claim
Token Claim Name: tenant
Claim Value: acme-corp

User Realm Role Mapper — maps realm roles into a custom claim (useful if you want roles as a flat array instead of nested under realm_access):

Mapper Type: User Realm Role
Token Claim Name: roles
Multivalued: on

ID Token vs Access Token vs Userinfo

This distinction trips up almost every team:

  • ID Token: A JWT issued alongside the access token in OIDC. Contains user identity claims (sub, name, email). Consumed by the client application to learn who the user is. Should be short-lived and not used for API authorization.
  • Access Token: The credential presented to APIs. May be a JWT (opaque tokens are also supported). APIs validate this token’s signature and check the aud claim. Do not add sensitive PII here — this token gets logged in reverse proxies.
  • UserInfo Endpoint (/protocol/openid-connect/userinfo): Returns user claims on demand. Useful when you want to keep access tokens small (no embedded claims) and fetch fresh user data per request.

The Add to access token / Add to ID token / Add to userinfo toggles on each mapper let you route claims to the right token type.


User Federation: LDAP and Active Directory

User federation lets Keycloak authenticate against an existing directory without migrating users into its database. LDAP/AD federation is the most common enterprise integration.

Provider Configuration

Navigate to User Federation → Add LDAP provider in the Admin Console. Critical settings:

Setting Active Directory OpenLDAP
Vendor Active Directory Other
Connection URL ldap://dc01.corp.example.com:389 ldap://ldap.example.com:389
Enable StartTLS recommended recommended
Bind DN CN=keycloak-svc,OU=ServiceAccounts,DC=corp,DC=example,DC=com cn=keycloak,dc=example,dc=com
Bind Credential (service account password) (bind password)
Users DN OU=Users,DC=corp,DC=example,DC=com ou=people,dc=example,dc=com
User object classes person, organizationalPerson, user inetOrgPerson, organizationalPerson
Username LDAP attribute sAMAccountName uid
RDN LDAP attribute cn uid
UUID LDAP attribute objectGUID entryUUID
User DN attribute distinguishedName entryDN

Edit Modes

  • READ_ONLY: Passwords authenticated against LDAP. User data read from LDAP; cannot be modified in Keycloak. Use this for AD where you don’t want Keycloak writing back.
  • WRITABLE: Keycloak writes attribute changes back to LDAP. Requires write permissions on the bind account.
  • UNSYNCED: Users imported from LDAP but stored locally; Keycloak data and LDAP diverge. Useful during migrations.

Sync Configuration

  • Periodic Full Sync: Scans all LDAP users on a schedule. Set to every 24 hours for large directories.
  • Periodic Changed Users Sync: Syncs only users modified since last sync (requires modifyTimestamp support). Set to every 15 minutes.
  • Import Users toggle: When enabled, Keycloak creates local user records mapped to LDAP. When disabled, no local records — each auth goes directly to LDAP.

LDAP Attribute Mappers

Add attribute mappers to import LDAP attributes into user profile fields:

  • User Attribute Mapper: ldap.attribute=mail → user.model.attribute=email
  • Full Name Mapper: cn → first + last name
  • MSAD Account Controls Mapper (AD-specific): maps pwdLastSet, userAccountControl for account enabled/disabled and password expiry

Group Mapper

The Group Mapper syncs LDAP groups into Keycloak groups:

LDAP Groups DN: OU=Groups,DC=corp,DC=example,DC=com
Group Name LDAP Attribute: cn
Group Object Classes: group
Membership LDAP Attribute: member
Membership Attribute Type: DN
Drop non-existing groups during sync: true

Kerberos Integration

For true Kerberos SSO (SPNEGO/negotiate):

  1. Enable Allow Kerberos Authentication on the LDAP federation provider
  2. Set Kerberos Realm (e.g., CORP.EXAMPLE.COM)
  3. Set Server Principal (HTTP/keycloak.corp.example.com@CORP.EXAMPLE.COM)
  4. Set KeyTab path (/etc/krb5.keytab — mount this as a Secret in Kubernetes)
  5. Enable Use Kerberos For Password Authentication to use Kerberos credentials for initial LDAP bind

Debugging LDAP Connectivity

1
2
3
4
5
6
7
8
9
# Test LDAP connection from within the Keycloak pod
ldapsearch -H ldap://dc01.corp.example.com:389 \
  -D "CN=keycloak-svc,OU=ServiceAccounts,DC=corp,DC=example,DC=com" \
  -w "${LDAP_BIND_PASS}" \
  -b "OU=Users,DC=corp,DC=example,DC=com" \
  "(sAMAccountName=testuser)"

# Enable LDAP debug logging in Keycloak
KC_LOG_LEVEL=org.keycloak.storage.ldap:DEBUG

The Admin Console also has a Test connection and Test authentication button on the LDAP federation config page — use them before saving.


Identity Providers: Social Login and External Federation

Keycloak can broker external identity providers (IdPs), letting users log in via GitHub, Google, corporate SAML IdPs, or other OIDC providers.

Adding an External OIDC IdP

Navigate to Identity Providers → OpenID Connect v1.0 (or Social → GitHub, Google, etc.). Keycloak discovers endpoint URLs via the .well-known/openid-configuration URL automatically.

For GitHub:

  1. Create an OAuth App at github.com/settings/developers
  2. Set callback URL to https://auth.example.com/realms/myrealm/broker/github/endpoint
  3. In Keycloak: Client ID and Secret from GitHub, Scopes: user:email

First Login Flow

When a federated user authenticates for the first time, Keycloak runs the First Broker Login flow. The default flow checks if an existing local user matches by email or username. Options:

  • Create new user: always create a local account linked to the IdP identity
  • Link to existing account: merge with an existing local account if email matches (requires email verification)
  • Confirm link with existing account by re-authentication: prompts the user to authenticate their existing account before linking

Customize this flow per-IdP. The default is usually fine for OIDC social providers but needs thought for enterprise SAML federation.

Passing Through IdP Tokens

Enable Store Tokens and Stored Tokens Readable on the IdP config to preserve the upstream access token. Clients can then retrieve it via the account service or REST API for calling upstream APIs on behalf of the user.


Authentication Flows

Authentication flows define the execution steps a user goes through during login, registration, or credential reset. They’re one of Keycloak’s most powerful features and one of its most complex configuration surfaces.

Built-in Flows

  • browser: The standard login form (username/password), with optional OTP as a conditional step
  • direct grant: Resource Owner Password Credentials (ROPC) flow — avoid in new applications
  • registration: Sign-up flow
  • reset credentials: Forgot-password flow
  • first broker login: As above
  • docker auth: Docker Registry v2 authentication

Execution Requirements

Each step in a flow has a requirement:

  • Required: Must succeed; failure blocks the flow
  • Alternative: Success of any one alternative step satisfies this level
  • Conditional: Runs only if a condition evaluates to true
  • Disabled: Skipped entirely

Adding OTP (TOTP)

To require TOTP as a second factor:

  1. Duplicate the browser flow
  2. After the Username Password Form step, add a subflow with Alternative requirement
  3. Add OTP Form as a Required step within the subflow
  4. Set the flow as the binding for Browser Flow in Realm Settings

Keycloak supports both TOTP (time-based, Google Authenticator, FreeOTP, Authy) and HOTP (counter-based). The QR code registration is handled by the OTP Setup Required Action.

WebAuthn and Passkeys

As of Keycloak 26.4, passkeys are fully supported (promoted from preview). WebAuthn enables both second-factor hardware tokens and passwordless authentication.

WebAuthn as Second Factor:

  1. Register the WebAuthn Authenticator execution in the browser flow at the same level as OTP Form
  2. Users register a security key or platform authenticator via Required Action
  3. The WebAuthn Authenticator mapper in the account console handles device management

Passkeys (Passwordless):

  • Keycloak 26.4 introduced conditional UI passkeys — the login form checks for available passkeys before showing the password field
  • No browser flow modification required; passkeys are automatically offered
  • The new Conditional - credential authenticator checks whether a specific credential type was used during authentication

Conditional Executions

Conditional subflows let you apply MFA selectively:

Condition - User Role: required_role=mfa-required → Required
  OTP Form: Required

Available conditions:

  • Condition - User Role: checks realm or client role membership
  • Condition - User Configured: checks if the user has configured a specific credential
  • Condition - User Attribute: checks user attribute value
  • Condition - Sub-Flow Result: checks result of a prior subflow

Recovery Codes

Keycloak 26.3 promoted Recovery Codes from preview to supported. Enable the generate-recovery-codes Required Action to let users generate backup codes for when their OTP device is unavailable. Configure in the authentication flow alongside OTP/WebAuthn.


Events and Audit Logging

Event Types

Keycloak distinguishes two event categories:

Login (User) Events: LOGIN, LOGIN_ERROR, REGISTER, REGISTER_ERROR, LOGOUT, CODE_TO_TOKEN, CODE_TO_TOKEN_ERROR, REFRESH_TOKEN, REFRESH_TOKEN_ERROR, CLIENT_LOGIN, CLIENT_LOGIN_ERROR, TOKEN_EXCHANGE, IDENTITY_PROVIDER_LOGIN, RESET_PASSWORD, UPDATE_PROFILE, GRANT_CONSENT, REVOKE_GRANT

Admin Events: All Admin REST API calls: realm creation, user updates, client modifications, role assignments, etc. Admin events have resourceType (USER, CLIENT, REALM, etc.), operationType (CREATE, UPDATE, DELETE, ACTION), resourcePath, and a representation field containing the changed object.

Built-in Event Listeners

  • jboss-logging (always active): writes events to the Keycloak log. ERROR events are logged at WARN level; DEBUG-level events require enabling specific event types.
  • email: sends email notifications to users on login errors. Requires SMTP config.

Enabling Event Storage

Go to Realm Settings → Events:

  1. Enable Save Events for user events
  2. Enable Save Admin Events and optionally Include Representation
  3. Set Expiration: events older than this are purged (default: never — set this to 30-90 days or your database will grow unbounded)
  4. Select specific event types to store (default: all error types only)

Custom Event Listener SPI for SIEM Integration

To ship events to a SIEM (Splunk, Elasticsearch, Datadog, etc.), implement a custom EventListenerProvider:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// EventListenerProviderFactory
public class SiemEventListenerProviderFactory 
    implements EventListenerProviderFactory {
    
    @Override
    public EventListenerProvider create(KeycloakSession session) {
        return new SiemEventListenerProvider(session, 
            System.getenv("SIEM_ENDPOINT"),
            System.getenv("SIEM_API_KEY"));
    }
    
    @Override
    public String getId() {
        return "siem-listener";
    }
    
    @Override
    public void init(Config.Scope config) {}
    
    @Override
    public void postInit(KeycloakSessionFactory factory) {}
    
    @Override
    public void close() {}
}

// EventListenerProvider
public class SiemEventListenerProvider implements EventListenerProvider {
    
    @Override
    public void onEvent(Event event) {
        // Ship login events to SIEM
        Map<String, Object> payload = new HashMap<>();
        payload.put("type", event.getType().name());
        payload.put("realmId", event.getRealmId());
        payload.put("userId", event.getUserId());
        payload.put("ipAddress", event.getIpAddress());
        payload.put("error", event.getError());
        payload.put("details", event.getDetails());
        payload.put("time", event.getTime());
        sendToSiem(payload);
    }
    
    @Override
    public void onEvent(AdminEvent event, boolean includeRepresentation) {
        // Ship admin events to SIEM
        Map<String, Object> payload = new HashMap<>();
        payload.put("type", "ADMIN_" + event.getOperationType().name());
        payload.put("resourceType", event.getResourceType().name());
        payload.put("resourcePath", event.getResourcePath());
        payload.put("authUserId", event.getAuthDetails().getUserId());
        payload.put("authIpAddress", event.getAuthDetails().getIpAddress());
        if (includeRepresentation) {
            payload.put("representation", event.getRepresentation());
        }
        sendToSiem(payload);
    }
    
    @Override
    public void close() {}
}

Register by creating META-INF/services/org.keycloak.events.EventListenerProviderFactory with your factory class name. Package as a JAR, drop in providers/, and register in the realm’s Event Listeners list.


Kubernetes Deployment with High Availability

This section covers both the Operator-based deployment (recommended) and raw Deployment YAML (for environments without the Operator).

The Keycloak Operator

The official Keycloak Operator is installed from OperatorHub or directly from the Keycloak GitHub releases. The API version is k8s.keycloak.org/v2beta1 (not v2alpha1 — that was superseded). The Operator manages:

  • Keycloak cluster lifecycle
  • TLS certificate integration
  • Database secret management
  • Rolling updates with zero downtime (enabled by default in 26.6)
  • Automatic ServiceMonitor creation for Prometheus

Install the Operator:

1
2
3
4
# Via kubectl (replace VERSION with current release, e.g. 26.6.2)
kubectl apply -f https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/refs/tags/26.6.2/kubernetes/keycloaks.k8s.keycloak.org-v1.yml
kubectl apply -f https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/refs/tags/26.6.2/kubernetes/keycloakrealmimports.k8s.keycloak.org-v1.yml
kubectl apply -f https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/refs/tags/26.6.2/kubernetes/kubernetes.yml

Database Secret:

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Secret
metadata:
  name: keycloak-db-secret
  namespace: keycloak
type: Opaque
stringData:
  username: keycloak
  password: "REPLACE_WITH_STRONG_PASSWORD"

PostgreSQL StatefulSet (production-grade — use a managed database or CloudNativePG in real deployments):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgresql
  namespace: keycloak
spec:
  serviceName: postgresql
  replicas: 1
  selector:
    matchLabels:
      app: postgresql
  template:
    metadata:
      labels:
        app: postgresql
    spec:
      containers:
        - name: postgresql
          image: postgres:16
          env:
            - name: POSTGRES_DB
              value: keycloak
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: keycloak-db-secret
                  key: username
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: keycloak-db-secret
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "2"
              memory: "2Gi"
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "keycloak"]
            initialDelaySeconds: 5
            periodSeconds: 10
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
  name: postgresql
  namespace: keycloak
spec:
  selector:
    app: postgresql
  ports:
    - port: 5432
      targetPort: 5432

Keycloak CR — Production HA Deployment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
apiVersion: k8s.keycloak.org/v2beta1
kind: Keycloak
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app: keycloak
spec:
  instances: 3
  
  image: quay.io/keycloak/keycloak:26.6.2
  
  hostname:
    hostname: auth.example.com
    # Optional: separate admin console hostname (recommended)
    # admin: auth-admin.internal.example.com
  
  db:
    vendor: postgres
    host: postgresql
    database: keycloak
    poolMinSize: 30
    poolInitialSize: 30
    poolMaxSize: 30
    usernameSecret:
      name: keycloak-db-secret
      key: username
    passwordSecret:
      name: keycloak-db-secret
      key: password
  
  http:
    # Use tlsSecret for HTTPS passthrough at Keycloak
    tlsSecret: keycloak-tls-secret
    # OR: use httpEnabled for TLS termination at ingress
    # httpEnabled: true
  
  proxy:
    # For TLS termination at ingress/LB (replaces deprecated KC_PROXY=edge)
    headers: xforwarded
  
  resources:
    requests:
      cpu: "2"
      memory: "1250Mi"
    limits:
      cpu: "6"
      memory: "2250Mi"
  
  features:
    enabled:
      - organization           # Multi-tenancy GA feature
      - token-exchange         # Enable token exchange
      - rolling-updates:v2    # Zero-downtime rolling updates (26.6 default)
  
  additionalOptions:
    - name: metrics-enabled
      value: "true"
    - name: health-enabled
      value: "true"
    - name: log-console-output
      value: json
    # Load shedding — return 503 when queue fills
    - name: http-max-queued-requests
      value: "1000"
    # JVM tuning via env (see spec.env below)
  
  # Ingress configuration
  ingress:
    enabled: true
    annotations:
      nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
      nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
      # Sticky sessions are important for Keycloak clustering
      nginx.ingress.kubernetes.io/affinity: "cookie"
      nginx.ingress.kubernetes.io/session-cookie-name: "KC_ROUTE"
      nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
      nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
  
  # JVM heap configuration
  env:
    - name: JAVA_OPTS_KC_HEAP
      value: "-Xms512m -Xmx1g"
    - name: JAVA_OPTS_APPEND
      value: "-Djava.net.preferIPv4Stack=true -XX:+UseG1GC"

PodDisruptionBudget (create separately):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: keycloak-pdb
  namespace: keycloak
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: keycloak

KeycloakRealmImport CRD (for GitOps realm management):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: k8s.keycloak.org/v2beta1
kind: KeycloakRealmImport
metadata:
  name: myrealm-import
  namespace: keycloak
spec:
  keycloakCRName: keycloak
  realm:
    realm: myrealm
    enabled: true
    displayName: "My Application Realm"
    loginTheme: custom-theme
    sslRequired: all
    bruteForceProtected: true
    failureFactor: 5
    waitIncrementSeconds: 30
    maxFailureWaitSeconds: 900
    passwordPolicy: "length(12) and notUsername and notEmail and digits(1) and specialChars(1) and passwordHistory(5)"
    defaultSignatureAlgorithm: RS256
    # ... rest of realm configuration

Raw Deployment YAML (Without Operator)

For environments where you can’t install the Operator:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
---
# Namespace
apiVersion: v1
kind: Namespace
metadata:
  name: keycloak
---
# ServiceAccount with cluster permissions for JGroups discovery
apiVersion: v1
kind: ServiceAccount
metadata:
  name: keycloak
  namespace: keycloak
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: keycloak-cluster-role
  namespace: keycloak
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: keycloak-cluster-rolebinding
  namespace: keycloak
subjects:
  - kind: ServiceAccount
    name: keycloak
    namespace: keycloak
roleRef:
  kind: Role
  name: keycloak-cluster-role
  apiGroup: rbac.authorization.k8s.io
---
# Headless service for JGroups cluster discovery (DNS_PING)
apiVersion: v1
kind: Service
metadata:
  name: keycloak-headless
  namespace: keycloak
  labels:
    app: keycloak
spec:
  clusterIP: None  # Headless — DNS returns all pod IPs
  selector:
    app: keycloak
  ports:
    - name: jgroups
      port: 7800
      targetPort: 7800
---
# HTTP service for ingress
apiVersion: v1
kind: Service
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app: keycloak
spec:
  selector:
    app: keycloak
  ports:
    - name: http
      port: 8080
      targetPort: 8080
    - name: management
      port: 9000
      targetPort: 9000
---
# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app: keycloak
spec:
  replicas: 3
  selector:
    matchLabels:
      app: keycloak
  # Recreate is safer than RollingUpdate if you haven't tested rolling updates
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: keycloak
    spec:
      serviceAccountName: keycloak
      
      # Spread pods across nodes and zones
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: keycloak
              topologyKey: kubernetes.io/hostname
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: keycloak
                topologyKey: topology.kubernetes.io/zone
      
      containers:
        - name: keycloak
          image: quay.io/keycloak/keycloak:26.6.2
          command: ["/opt/keycloak/bin/kc.sh"]
          args: ["start", "--optimized"]
          
          env:
            # Database
            - name: KC_DB
              value: postgres
            - name: KC_DB_URL
              value: "jdbc:postgresql://postgresql:5432/keycloak"
            - name: KC_DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: keycloak-db-secret
                  key: username
            - name: KC_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: keycloak-db-secret
                  key: password
            - name: KC_DB_POOL_MIN_SIZE
              value: "15"
            - name: KC_DB_POOL_INITIAL_SIZE
              value: "15"
            - name: KC_DB_POOL_MAX_SIZE
              value: "15"
            
            # Hostname / proxy
            - name: KC_HOSTNAME
              value: "auth.example.com"
            - name: KC_HTTP_ENABLED
              value: "true"      # Required when KC_PROXY_HEADERS is set
            - name: KC_PROXY_HEADERS
              value: xforwarded  # Replaces deprecated KC_PROXY=edge
            
            # Clustering — jdbc-ping is the default since 26.1 for non-Operator
            # For DNS_PING (legacy, still works):
            # - name: KC_CACHE
            #   value: ispn
            # - name: KC_CACHE_STACK
            #   value: kubernetes
            # - name: JAVA_OPTS_APPEND
            #   value: "-Djgroups.dns.query=keycloak-headless.keycloak.svc.cluster.local"
            
            # Observability
            - name: KC_METRICS_ENABLED
              value: "true"
            - name: KC_HEALTH_ENABLED
              value: "true"
            - name: KC_LOG_CONSOLE_OUTPUT
              value: json
            
            # JVM
            - name: JAVA_OPTS_KC_HEAP
              value: "-Xms512m -Xmx1g"
            - name: JAVA_OPTS_APPEND
              value: "-XX:+UseG1GC -Djava.net.preferIPv4Stack=true"
          
          ports:
            - containerPort: 8080
              name: http
            - containerPort: 9000
              name: management
            - containerPort: 7800
              name: jgroups
            - containerPort: 57800
              name: jgroups-fd
          
          resources:
            requests:
              cpu: "2"
              memory: "1250Mi"
            limits:
              cpu: "6"
              memory: "2250Mi"
          
          # Startup probe: allow up to 3 minutes for first startup
          startupProbe:
            httpGet:
              path: /health/started
              port: 9000
              scheme: HTTP
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 18
          
          # Readiness: only route traffic to initialized pods
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 9000
              scheme: HTTP
            periodSeconds: 10
            failureThreshold: 3
          
          # Liveness: restart pods that are stuck
          livenessProbe:
            httpGet:
              path: /health/live
              port: 9000
              scheme: HTTP
            periodSeconds: 30
            failureThreshold: 3

Cache Configuration: jdbc-ping vs kubernetes Stack

Since Keycloak 26.1, jdbc-ping is the default cache stack for non-Operator deployments. It uses the Keycloak database to store cluster membership information — no UDP multicast, no DNS configuration, no headless service required (though a headless service is still good practice for network policies).

The kubernetes (DNS_PING) stack still works but is deprecated. If you’re starting fresh, let jdbc-ping be the default. If you’re on the Operator, it continues to configure kubernetes stack automatically but the Operator team is working on migrating to jdbc-ping.

Probes and Health Endpoints

All health and metrics endpoints are served on port 9000 (the management interface), isolated from application traffic on port 8080/8443.

Endpoint Purpose
GET :9000/health Overall health (combines ready + live)
GET :9000/health/ready Readiness — Keycloak has initialized and can serve requests
GET :9000/health/live Liveness — process is alive
GET :9000/health/started Started — use this for startupProbe
GET :9000/metrics Prometheus metrics (Micrometer format)

Enable them at build time: kc.sh build --health-enabled=true --metrics-enabled=true, or via KC_HEALTH_ENABLED=true and KC_METRICS_ENABLED=true environment variables.

Prometheus ServiceMonitor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: keycloak
  namespace: keycloak
  labels:
    app: keycloak
    release: prometheus  # match your Prometheus operator labelSelector
spec:
  selector:
    matchLabels:
      app: keycloak
  endpoints:
    - port: management
      path: /metrics
      interval: 30s
      scrapeTimeout: 10s
      scheme: http  # management port is HTTP by default

The Keycloak Operator creates this ServiceMonitor automatically when metrics-enabled: "true" is set in additionalOptions. For raw deployments, apply the above manually.

Key metrics to alert on:

  • keycloak_logins_total — successful logins by realm and provider
  • keycloak_failed_login_attempts_total — failed logins by realm and error
  • keycloak_active_sessions — active SSO sessions by realm
  • jvm_memory_used_bytes — heap/non-heap usage
  • agroal_connections_active_count — database connection pool active
  • vendor_statistics_entries — Infinispan cache entry counts

Local Development: Docker Compose

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
version: "3.8"

services:
  postgres:
    image: postgres:16
    container_name: keycloak-postgres
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak_dev_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - keycloak-net

  keycloak:
    image: quay.io/keycloak/keycloak:26.6.2
    container_name: keycloak
    command: start-dev  # Dev mode only — insecure defaults
    environment:
      # Bootstrap admin
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      
      # Database
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak_dev_password
      
      # Dev settings
      KC_HOSTNAME: localhost
      KC_HOSTNAME_STRICT: "false"
      KC_HOSTNAME_STRICT_HTTPS: "false"
      KC_HTTP_ENABLED: "true"
      KC_HEALTH_ENABLED: "true"
      KC_METRICS_ENABLED: "true"
      
      # Theme hot-reload for development
      KC_SPI_THEME_STATIC_MAX_AGE: "-1"
      KC_SPI_THEME_CACHE_THEMES: "false"
      KC_SPI_THEME_CACHE_TEMPLATES: "false"
    
    ports:
      - "8080:8080"     # Keycloak HTTP
      - "9000:9000"     # Management (health + metrics)
    
    volumes:
      # Mount custom themes for local development
      - ./themes:/opt/keycloak/themes
      # Mount custom providers/SPIs
      - ./providers:/opt/keycloak/providers
    
    depends_on:
      postgres:
        condition: service_healthy
    
    networks:
      - keycloak-net

volumes:
  postgres_data:

networks:
  keycloak-net:
    driver: bridge

Access: http://localhost:8080/admin — Username: admin, Password: admin


Theme Customization

Keycloak supports five theme types: login, account, admin, email, and welcome. Login and email themes are the most commonly customized.

Directory Structure

themes/
└── my-brand/
    ├── login/
    │   ├── theme.properties          # parent=keycloak  OR  parent=base
    │   ├── login.ftl                 # Override login page template
    │   ├── register.ftl
    │   ├── error.ftl
    │   ├── resources/
    │   │   ├── css/
    │   │   │   └── login.css
    │   │   ├── js/
    │   │   └── img/
    │   │       └── logo.png
    │   └── messages/
    │       ├── messages_en.properties
    │       └── messages_de.properties
    ├── email/
    │   ├── theme.properties           # parent=base
    │   ├── messages/
    │   │   └── messages_en.properties
    │   └── html/
    │       ├── email-verification.ftl
    │       └── password-reset.ftl
    └── account/
        └── theme.properties           # parent=keycloak.v3

theme.properties

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Extend the default Keycloak login theme
parent=keycloak

# Import common resources from base
import=common/keycloak

# Custom CSS (space-separated, appended to parent's styles)
styles=css/login.css css/my-brand.css

# Custom scripts
scripts=js/my-custom.js

# Supported locales
locales=en,de,fr

For a minimal CSS-only customization, extend parent=keycloak and override CSS variables. The Keycloak login theme uses CSS custom properties for colors, fonts, and spacing, making a CSS-only theme straightforward.

Freemarker Templates

Override specific .ftl files to change HTML structure. Copy the template from the base theme and modify. The base theme is embedded in keycloak-themes-<version>.jar — extract it to see all available templates:

1
2
3
4
# Extract base theme from running container
docker run --rm --entrypoint="" quay.io/keycloak/keycloak:26.6.2 \
  cat /opt/keycloak/lib/lib/main/org.keycloak.keycloak-themes-26.6.2.jar \
  | jar -xf - theme/

Keycloakify: React-based Login Themes

For teams who want to build login UIs in React (with TypeScript, component testing, Storybook integration), Keycloakify is the de facto standard. It compiles a React application into a Keycloak JAR theme using keycloakify build. The resulting JAR drops into providers/.

Account Console (v3)

The Account Console was rewritten in React (Keycloak Account Console v3). Custom account themes using the v3 base can extend parent=keycloak.v3. The React source is available in the Keycloak repository if you need to fork and customize at the component level.

JAR Deployment

For production, package themes as JARs:

META-INF/
  keycloak-themes.json
theme/
  my-brand/
    login/
      ...

META-INF/keycloak-themes.json:

1
2
3
4
5
6
{
  "themes": [{
    "name": "my-brand",
    "types": ["login", "email", "account"]
  }]
}

Build: jar cf my-brand-theme.jar -C src/ .

Drop the JAR into providers/ and rebuild: kc.sh build. No restart needed if using the dynamic provider loader (enabled by default in Keycloak 26).

Kubernetes Theme Deployment

Via init container (preferred — keeps the main image immutable):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
initContainers:
  - name: theme-provider
    image: my-registry/keycloak-theme:1.0.0
    command: ["sh", "-c", "cp /themes/my-brand-theme.jar /providers/"]
    volumeMounts:
      - name: providers
        mountPath: /providers

containers:
  - name: keycloak
    volumeMounts:
      - name: providers
        mountPath: /opt/keycloak/providers

volumes:
  - name: providers
    emptyDir: {}

Via ConfigMap (for small CSS/resource-only themes, not JAR):

ConfigMaps have a 1MB limit. A full theme JAR typically exceeds this. Use an init container with a container image for JAR-based themes.


Production Operations

The kc.sh Build Pipeline

The production deployment workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Custom production image with pre-compiled configuration
FROM quay.io/keycloak/keycloak:26.6.2 AS builder

# Copy custom providers/themes
COPY providers/ /opt/keycloak/providers/

# Run the build step with your feature flags and database vendor
RUN /opt/keycloak/bin/kc.sh build \
  --db=postgres \
  --features=organization,token-exchange \
  --metrics-enabled=true \
  --health-enabled=true \
  --cache=ispn \
  --http-relative-path=/

FROM quay.io/keycloak/keycloak:26.6.2
COPY --from=builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized"]

The --optimized flag tells kc.sh start to skip the build phase. Without it, Keycloak runs a build step every time it starts — slow and risky in production containers.

Database Schema Migration

Keycloak handles schema migrations automatically on startup (via Liquibase). There is no manual migration step. When upgrading Keycloak versions:

  1. Ensure the new Keycloak version’s migration scripts are compatible with your current schema version
  2. Check the Upgrading Guide for each version
  3. Run one pod at a time on initial upgrade (reduce replicas to 1 while migration runs, then scale back up)
  4. Back up your database before any upgrade

Admin CLI (kcadm.sh)

kcadm.sh is the command-line admin client. Essential for scripting and CI/CD pipelines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# Authenticate (credentials stored in ~/.keycloak/kcadm.config)
kcadm.sh config credentials \
  --server https://auth.example.com \
  --realm master \
  --user admin \
  --password "${ADMIN_PASSWORD}"

# Create a realm
kcadm.sh create realms \
  -s realm=myrealm \
  -s enabled=true \
  -s loginTheme=my-brand

# Create a user
kcadm.sh create users \
  -r myrealm \
  -s username=alice \
  -s email=alice@example.com \
  -s emailVerified=true \
  -s enabled=true

# Set a user's password
USER_ID=$(kcadm.sh get users -r myrealm -q username=alice --fields id --format csv --noquotes)
kcadm.sh set-password -r myrealm --userid "${USER_ID}" --new-password "${USER_PASSWORD}"

# Create a confidential client
kcadm.sh create clients -r myrealm \
  -s clientId=my-api \
  -s publicClient=false \
  -s serviceAccountsEnabled=true \
  -s 'redirectUris=["https://app.example.com/callback"]'

# Add a realm role
kcadm.sh create roles -r myrealm -s name=app-user -s description="Application User"

# Assign a role to a user
kcadm.sh add-roles -r myrealm --uusername alice --rolename app-user

# Get realm settings (for export/diff)
kcadm.sh get realms/myrealm > myrealm-settings.json

# Enable brute force protection
kcadm.sh update realms/myrealm \
  -s bruteForceProtected=true \
  -s failureFactor=5

# Trigger full LDAP sync
kcadm.sh create user-storage/$(kcadm.sh get components -r myrealm -q name=my-ldap --fields id --format csv --noquotes)/sync \
  -r myrealm \
  -s action=triggerFullSync

Realm Export Without Secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Export without LDAP bind credentials (they're redacted to ** in console export anyway)
# For a script-safe export use kc.sh, not kcadm.sh:
kc.sh export \
  --realm myrealm \
  --file /tmp/myrealm-export.json \
  --users skip  # Skip user data for GitOps-safe exports

# For full export (includes hashed passwords — handle as secret):
kc.sh export \
  --realm myrealm \
  --dir /tmp/myrealm-full/ \
  --users realm_file

Cache Invalidation

When you modify realm configuration through the Admin Console or REST API, Keycloak handles cache invalidation automatically via the distributed Infinispan cache — all cluster nodes see the change via the replication channel. You do not need to restart pods for configuration changes.

The exception: changes to the build configuration (feature flags, database vendor, enabled providers) require a rebuild and rolling restart.

Realm JSON Export Snippet (GitOps)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
{
  "realm": "myrealm",
  "enabled": true,
  "displayName": "My Application",
  "sslRequired": "all",
  "registrationAllowed": false,
  "loginWithEmailAllowed": true,
  "duplicateEmailsAllowed": false,
  "resetPasswordAllowed": true,
  "editUsernameAllowed": false,
  "bruteForceProtected": true,
  "permanentLockout": false,
  "maxFailureWaitSeconds": 900,
  "failureFactor": 5,
  "passwordPolicy": "length(12) and notUsername and notEmail and digits(1) and specialChars(1) and passwordHistory(5)",
  "defaultSignatureAlgorithm": "RS256",
  "ssoSessionMaxLifespan": 36000,
  "ssoSessionIdleTimeout": 1800,
  "accessTokenLifespan": 300,
  "refreshTokenMaxReuse": 0,
  "roles": {
    "realm": [
      { "name": "app-user", "description": "Standard application user" },
      { "name": "app-admin", "description": "Application administrator" }
    ]
  },
  "clients": [
    {
      "clientId": "frontend-spa",
      "enabled": true,
      "publicClient": true,
      "protocol": "openid-connect",
      "standardFlowEnabled": true,
      "redirectUris": ["https://app.example.com/*"],
      "webOrigins": ["https://app.example.com"],
      "attributes": {
        "pkce.code.challenge.method": "S256"
      },
      "defaultClientScopes": ["openid", "profile", "email"],
      "optionalClientScopes": ["roles", "offline_access", "phone"]
    },
    {
      "clientId": "backend-api",
      "enabled": true,
      "publicClient": false,
      "protocol": "openid-connect",
      "serviceAccountsEnabled": true,
      "authorizationServicesEnabled": false,
      "secret": "${BACKEND_API_CLIENT_SECRET}",
      "defaultClientScopes": ["openid", "profile"]
    }
  ],
  "identityProviders": [],
  "userFederation": [
    {
      "name": "corp-ldap",
      "providerId": "ldap",
      "enabled": true,
      "config": {
        "vendor": "ad",
        "connectionUrl": "ldap://dc01.corp.example.com:389",
        "useTruststoreSpi": "ldapsOnly",
        "startTls": "true",
        "bindDn": "CN=keycloak-svc,OU=ServiceAccounts,DC=corp,DC=example,DC=com",
        "bindCredential": "${LDAP_BIND_PASSWORD}",
        "usersDn": "OU=Users,DC=corp,DC=example,DC=com",
        "userObjectClasses": "person, organizationalPerson, user",
        "usernameLDAPAttribute": "sAMAccountName",
        "rdnLDAPAttribute": "cn",
        "uuidLDAPAttribute": "objectGUID",
        "editMode": "READ_ONLY",
        "importEnabled": "true",
        "syncRegistrations": "false",
        "fullSyncPeriod": "86400",
        "changedSyncPeriod": "900",
        "searchScope": "2",
        "pagination": "true",
        "batchSizeForSync": "1000"
      }
    }
  ],
  "clientScopes": [
    {
      "name": "department",
      "protocol": "openid-connect",
      "attributes": {
        "include.in.token.scope": "true",
        "display.on.consent.screen": "false"
      },
      "protocolMappers": [
        {
          "name": "department-mapper",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usermodel-attribute-mapper",
          "config": {
            "userinfo.token.claim": "true",
            "user.attribute": "department",
            "id.token.claim": "false",
            "access.token.claim": "true",
            "claim.name": "department",
            "jsonType.label": "String"
          }
        }
      ]
    }
  ]
}

Common Pitfalls and Operational Reality

After running Keycloak at scale the same failure modes appear repeatedly. Know these before they happen to you.

1. Wildcard Redirect URI

Already covered, but worth repeating: https://app.example.com/* is reasonable. * by itself as a valid redirect is a textbook open redirector. Even https://app.example.com/* can be dangerous if your app has user-controlled path segments. Validate URI patterns in code reviews.

2. H2 in Production

The default embedded H2 database survives exactly one Keycloak pod restart. Data is written to data/h2/keycloak.mv.db inside the container filesystem. When the pod is replaced, that file is gone. Users disappear, sessions disappear, client secrets disappear. Always configure PostgreSQL (or MySQL/MariaDB) from day one. There is no migration path from H2 to PostgreSQL without a full realm export/reimport.

3. Running Apps in the Master Realm

The master realm admin account is essentially root for the entire Keycloak installation. An OIDC client in master that gets compromised can manage all other realms. Create a dedicated realm for every workload.

4. Token Expiry Confusion

Teams regularly confuse these four lifetimes:

  • Access token lifespan (default: 5 min): how long the JWT is valid for API calls
  • SSO session idle timeout (default: 30 min): inactivity window before the user session expires
  • SSO session max lifespan (default: 10 hours): absolute maximum session length
  • Refresh token lifespan: governed by SSO session settings, not a separate token setting

A user can be “logged in” (active SSO session) while their current access token is expired. The client is responsible for using the refresh token to get a new access token. Most OAuth2 client libraries handle this automatically.

Setting the access token lifespan to 30 minutes “to avoid frequent refreshes” is a mistake — it means a stolen access token is valid for 30 minutes with no way to revoke it (since JWTs are stateless).

5. No Brute Force Protection

Off by default. Every Keycloak instance that is publicly accessible needs brute force protection enabled. Without it, an attacker can attempt unlimited password guesses. Enable it in Realm Settings → Security Defenses.

6. Not Setting KC_HTTP_ENABLED=true With KC_PROXY_HEADERS

Since Keycloak 26 removed the old KC_PROXY=edge option, the replacement is KC_PROXY_HEADERS=xforwarded. But KC_PROXY_HEADERS alone doesn’t work — you must also set KC_HTTP_ENABLED=true to allow HTTP connections from the reverse proxy. Omitting this causes HTTPS required errors even when the user is connecting via HTTPS through the load balancer.

7. Clock Skew

JWTs use iat (issued at) and exp (expiration) Unix timestamps. If the Keycloak server clock is ahead of the client clock, tokens will appear expired immediately. If the client clock is ahead, tokens may pass expiry checks they should fail. Use NTP sync everywhere. Keycloak has a configurable Not Before Policy that can help with replay attacks but won’t fix clock skew.

8. CORS Misconfiguration

Set Web Origins on your client to the exact origins that should make cross-origin requests. + as web origin copies the list from Valid Redirect URIs (common shortcut). * allows all origins — acceptable for fully public APIs, a security risk for APIs with user-specific data.

9. Admin Console Export Includes Hashed Passwords

A realm export from kc.sh export includes Bcrypt/SHA-512 hashed passwords for local users. These cannot be reversed but they can be imported directly into another Keycloak instance, effectively copying all user credentials. Treat export files as secrets and don’t commit them unencrypted to Git.

10. Database Connection Pool Sizing

The default pool size is too small for production traffic. Set poolMinSize, poolInitialSize, and poolMaxSize to the same value (this matters for JDBC statement caching — varying min/max forces pool drain and refill, which flushes the statement cache). Start at 30 connections per pod for moderate traffic; monitor agroal_connections_active_count and adjust.

11. Ignoring the aud Claim in API Validation

Access tokens should contain the aud (audience) claim identifying which services should accept them. Without an Audience mapper on your API’s client scope, access tokens won’t contain your API’s client ID as an audience. If your API doesn’t validate aud, any valid Keycloak access token from any client in the realm can call it.

12. Session Limits Under Load

Keycloak stores session data in Infinispan (in-memory distributed cache). Under high concurrency with many concurrent sessions, memory pressure grows. Monitor keycloak_active_sessions per realm and jvm_memory_used_bytes. Tune SSO Session Max to balance user experience against memory consumption.


Key Architectural Decisions Checklist

Before going to production, verify:

  • PostgreSQL (not H2) configured and backed up
  • Master realm used only for Keycloak administration
  • Brute force protection enabled on all realms
  • SSL required set to all requests
  • Strong password policy configured
  • Exact redirect URIs for all clients (no bare *)
  • PKCE enabled for all public clients
  • Audience mappers configured for all backend APIs
  • Event storage enabled with expiration policy
  • Access token lifespan ≤ 5 minutes
  • Health and metrics endpoints enabled
  • PodDisruptionBudget in place (minAvailable: replicas-1)
  • Pod anti-affinity rules across nodes and zones
  • KC_PROXY_HEADERS=xforwarded + KC_HTTP_ENABLED=true (for proxy deployments)
  • LDAP bind credential rotated from initial setup
  • Theme customization tested across all locales
  • Realm export committed to Git (without user passwords if GitOps)
  • Upgrade runbook documented for database migration procedure
  • Grafana dashboards for keycloak_logins_total and keycloak_failed_login_attempts_total

What’s New in Keycloak 26.x

Running Keycloak 26.6 means access to:

  • Organizations (GA since 26.0): First-class B2B multi-tenancy within a single realm. Each organization gets its own IdPs, invitation workflows, and organization-scoped roles.
  • Passkeys (Supported since 26.4): Passwordless authentication with conditional UI. No browser flow modification required.
  • DPoP (Fully supported since 26.4): OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer for sender-constrained tokens.
  • FAPI 2 Final (26.4): Full support for the OpenID Foundation’s Financial-grade API 2.0 Security Profile and Message Signing.
  • Recovery Codes (Supported since 26.3): Backup codes for MFA recovery.
  • Zero-Downtime Patch Updates (26.6): Rolling updates within a minor release stream enabled by default in the Operator.
  • JWT Authorization Grant (26.6): RFC 7523 external-to-internal token exchange.
  • Federated Client Authentication (26.6): Clients authenticate using Kubernetes Service Account tokens or OIDC IdP credentials — no shared secret management.
  • Workflows (26.6): YAML-defined automation for realm administrative tasks.
  • jdbc-ping default (26.1): No more DNS_PING setup for clustering in most environments.
  • Token Exchange V1 deprecated: Migrate to the V2 Identity Brokering APIs.

The operational reality of running your own identity provider is that you’re taking on responsibility for the security critical path of every application in your organization. Keycloak makes that tractable — but only if the configuration is correct, the database is reliable, the clustering is healthy, and someone is watching the metrics. The reward is full control: custom authentication flows, your own branding, deep LDAP integration, and no per-MAU pricing. For organizations with the platform maturity to operate it, it’s an excellent choice. For organizations that need to focus engineering capacity elsewhere, managed Keycloak services (cloud-iam.com, Phase Two, Red Hat SSO on OpenShift) are worth the trade-off.

Sources:

Comments