Keycloak User Federation: Building a Custom Provider
Last updated: March 2026
Most organizations do not start with Keycloak. They have existing user databases — legacy applications with their own user tables, LDAP directories, HR systems, or custom authentication services. User federation allows Keycloak to authenticate users against these external stores without migrating the data upfront.
Keycloak ships with built-in federation providers for LDAP and Active Directory. But when your user store is a custom database, a REST API, or a proprietary system, you need to build a custom UserStorageProvider using Keycloak’s SPI (Service Provider Interface).
This guide walks through building a complete custom user federation provider, covering the provider factory, user model adapter, credential validation, lazy vs. eager loading strategies, caching, and deployment.
When to Use User Federation
User federation makes sense when:
- You are migrating to Keycloak gradually: Users authenticate against the legacy system until they reset their password, at which point Keycloak takes over credential management.
- The external user store is the source of truth: An HR system or directory service manages user lifecycle, and Keycloak should reflect those changes.
- You cannot migrate all users at once: Regulatory, technical, or business constraints prevent a big-bang migration.
User federation does NOT replace SCIM for ongoing user provisioning from external systems. Federation is about authentication delegation, while SCIM handles user lifecycle events (create, update, deactivate). For SCIM-based provisioning, see our SCIM feature page and the SCIM Endpoint Tester for debugging.
Architecture Overview
A custom UserStorageProvider consists of three main components:
- Provider Factory: Creates instances of your provider and defines its configuration properties
- Provider: Implements the user lookup, credential validation, and user model creation logic
- User Model Adapter: Wraps your external user data in Keycloak’s UserModel interface
The interaction flow:
User Login → Keycloak Auth Flow → UserStorageProvider.getUserByUsername()
→ External DB/API lookup
→ Return UserModel adapter
→ CredentialInputValidator.isValid()
→ Validate password against external store
→ Authentication success/failure
Project Setup
Create a Maven project with Keycloak dependencies:
<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>keycloak-custom-user-federation</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<keycloak.version>26.0.0</keycloak.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-storage</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<!-- Your external DB driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.postgresql:postgresql</include>
</includes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
The shade plugin bundles the PostgreSQL driver into your JAR since Keycloak does not include it by default (unless it is already your Keycloak database driver).
The External User Repository
First, define a simple data class and repository for your external user store:
// src/main/java/com/example/federation/ExternalUser.java
package com.example.federation;
public class ExternalUser {
private String id;
private String username;
private String email;
private String firstName;
private String lastName;
private String passwordHash;
private boolean enabled;
private long createdAt;
// Constructors
public ExternalUser() {}
public ExternalUser(String id, String username, String email,
String firstName, String lastName,
String passwordHash, boolean enabled, long createdAt) {
this.id = id;
this.username = username;
this.email = email;
this.firstName = firstName;
this.lastName = lastName;
this.passwordHash = passwordHash;
this.enabled = enabled;
this.createdAt = createdAt;
}
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public long getCreatedAt() { return createdAt; }
public void setCreatedAt(long createdAt) { this.createdAt = createdAt; }
}
// src/main/java/com/example/federation/ExternalUserRepository.java
package com.example.federation;
import org.jboss.logging.Logger;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class ExternalUserRepository {
private static final Logger LOG = Logger.getLogger(ExternalUserRepository.class);
private final String jdbcUrl;
private final String dbUsername;
private final String dbPassword;
public ExternalUserRepository(String jdbcUrl, String dbUsername, String dbPassword) {
this.jdbcUrl = jdbcUrl;
this.dbUsername = dbUsername;
this.dbPassword = dbPassword;
}
private Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl, dbUsername, dbPassword);
}
public ExternalUser findByUsername(String username) {
String sql = "SELECT id, username, email, first_name, last_name, "
+ "password_hash, enabled, created_at "
+ "FROM users WHERE LOWER(username) = LOWER(?)";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return mapResultSet(rs);
}
}
} catch (SQLException e) {
LOG.errorf("Error finding user by username: %s", e.getMessage());
}
return null;
}
public ExternalUser findByEmail(String email) {
String sql = "SELECT id, username, email, first_name, last_name, "
+ "password_hash, enabled, created_at "
+ "FROM users WHERE LOWER(email) = LOWER(?)";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, email);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return mapResultSet(rs);
}
}
} catch (SQLException e) {
LOG.errorf("Error finding user by email: %s", e.getMessage());
}
return null;
}
public ExternalUser findById(String id) {
String sql = "SELECT id, username, email, first_name, last_name, "
+ "password_hash, enabled, created_at "
+ "FROM users WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return mapResultSet(rs);
}
}
} catch (SQLException e) {
LOG.errorf("Error finding user by id: %s", e.getMessage());
}
return null;
}
public int getUserCount() {
String sql = "SELECT COUNT(*) FROM users WHERE enabled = true";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
} catch (SQLException e) {
LOG.errorf("Error counting users: %s", e.getMessage());
}
return 0;
}
public List<ExternalUser> searchUsers(String search, int firstResult, int maxResults) {
String sql = "SELECT id, username, email, first_name, last_name, "
+ "password_hash, enabled, created_at "
+ "FROM users "
+ "WHERE (LOWER(username) LIKE LOWER(?) "
+ " OR LOWER(email) LIKE LOWER(?) "
+ " OR LOWER(first_name) LIKE LOWER(?) "
+ " OR LOWER(last_name) LIKE LOWER(?)) "
+ "AND enabled = true "
+ "ORDER BY username "
+ "LIMIT ? OFFSET ?";
List<ExternalUser> users = new ArrayList<>();
String searchPattern = "%" + search + "%";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, searchPattern);
stmt.setString(2, searchPattern);
stmt.setString(3, searchPattern);
stmt.setString(4, searchPattern);
stmt.setInt(5, maxResults);
stmt.setInt(6, firstResult);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
users.add(mapResultSet(rs));
}
}
} catch (SQLException e) {
LOG.errorf("Error searching users: %s", e.getMessage());
}
return users;
}
public boolean validateCredentials(String username, String password) {
ExternalUser user = findByUsername(username);
if (user == null || user.getPasswordHash() == null) {
return false;
}
// Use BCrypt or whatever hashing your legacy system uses
return BCryptHelper.checkPassword(password, user.getPasswordHash());
}
private ExternalUser mapResultSet(ResultSet rs) throws SQLException {
return new ExternalUser(
rs.getString("id"),
rs.getString("username"),
rs.getString("email"),
rs.getString("first_name"),
rs.getString("last_name"),
rs.getString("password_hash"),
rs.getBoolean("enabled"),
rs.getLong("created_at")
);
}
}
// src/main/java/com/example/federation/BCryptHelper.java
package com.example.federation;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* Simple password verification helper.
* In production, use a proper BCrypt library like jBCrypt or Spring Security's BCryptPasswordEncoder.
*/
public class BCryptHelper {
public static boolean checkPassword(String plaintext, String hash) {
// If your legacy system uses BCrypt, use the jBCrypt library:
// return BCrypt.checkpw(plaintext, hash);
// If your legacy system uses SHA-256 (not recommended for new systems):
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] computed = digest.digest(plaintext.getBytes());
String computedHash = Base64.getEncoder().encodeToString(computed);
return MessageDigest.isEqual(
computedHash.getBytes(),
hash.getBytes()
);
} catch (NoSuchAlgorithmException e) {
return false;
}
}
}
User Model Adapter
The adapter wraps your external user data in Keycloak’s UserModel interface. Keycloak calls methods on this adapter to read user properties:
// src/main/java/com/example/federation/ExternalUserAdapter.java
package com.example.federation;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
public class ExternalUserAdapter extends AbstractUserAdapterFederatedStorage {
private final ExternalUser externalUser;
private final String keycloakId;
public ExternalUserAdapter(KeycloakSession session, RealmModel realm,
ComponentModel storageProviderModel,
ExternalUser externalUser) {
super(session, realm, storageProviderModel);
this.externalUser = externalUser;
// Create a Keycloak ID that includes the storage provider prefix
this.keycloakId = StorageId.keycloakId(storageProviderModel, externalUser.getId());
}
@Override
public String getId() {
return keycloakId;
}
@Override
public String getUsername() {
return externalUser.getUsername();
}
@Override
public void setUsername(String username) {
externalUser.setUsername(username);
}
@Override
public String getEmail() {
return externalUser.getEmail();
}
@Override
public void setEmail(String email) {
externalUser.setEmail(email);
}
@Override
public boolean isEmailVerified() {
return true; // Assuming legacy system verified emails
}
@Override
public String getFirstName() {
return externalUser.getFirstName();
}
@Override
public void setFirstName(String firstName) {
externalUser.setFirstName(firstName);
}
@Override
public String getLastName() {
return externalUser.getLastName();
}
@Override
public void setLastName(String lastName) {
externalUser.setLastName(lastName);
}
@Override
public boolean isEnabled() {
return externalUser.isEnabled();
}
@Override
public void setEnabled(boolean enabled) {
externalUser.setEnabled(enabled);
}
@Override
public Long getCreatedTimestamp() {
return externalUser.getCreatedAt();
}
@Override
public void setCreatedTimestamp(Long timestamp) {
externalUser.setCreatedAt(timestamp != null ? timestamp : 0);
}
@Override
public Map<String, List<String>> getAttributes() {
MultivaluedHashMap<String, String> attrs = new MultivaluedHashMap<>();
attrs.add("external_id", externalUser.getId());
attrs.add("source", "legacy-db");
return attrs;
}
@Override
public Stream<String> getAttributeStream(String name) {
Map<String, List<String>> attrs = getAttributes();
return attrs.containsKey(name) ? attrs.get(name).stream() : Stream.empty();
}
}
The AbstractUserAdapterFederatedStorage base class handles role mappings, group memberships, and other Keycloak-specific data by storing them in Keycloak’s own database. This means you can assign Keycloak roles and groups to federated users without modifying the external store.
The Provider Implementation
The provider implements the core federation logic:
// src/main/java/com/example/federation/ExternalUserStorageProvider.java
package com.example.federation;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.*;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserCountMethodsProvider;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryMethodsProvider;
import java.util.Map;
import java.util.stream.Stream;
public class ExternalUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
UserQueryMethodsProvider,
UserCountMethodsProvider,
CredentialInputValidator {
private static final Logger LOG = Logger.getLogger(ExternalUserStorageProvider.class);
private final KeycloakSession session;
private final ComponentModel model;
private final ExternalUserRepository repository;
public ExternalUserStorageProvider(KeycloakSession session, ComponentModel model,
ExternalUserRepository repository) {
this.session = session;
this.model = model;
this.repository = repository;
}
// --- UserLookupProvider ---
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
LOG.debugf("getUserByUsername: %s", username);
ExternalUser externalUser = repository.findByUsername(username);
if (externalUser == null) {
return null;
}
return new ExternalUserAdapter(session, realm, model, externalUser);
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
LOG.debugf("getUserByEmail: %s", email);
ExternalUser externalUser = repository.findByEmail(email);
if (externalUser == null) {
return null;
}
return new ExternalUserAdapter(session, realm, model, externalUser);
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
LOG.debugf("getUserById: %s", id);
// Extract the external ID from the Keycloak storage ID
String externalId = StorageId.externalId(id);
ExternalUser externalUser = repository.findById(externalId);
if (externalUser == null) {
return null;
}
return new ExternalUserAdapter(session, realm, model, externalUser);
}
// --- UserQueryMethodsProvider ---
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params,
Integer firstResult, Integer maxResults) {
String search = params.get(UserModel.SEARCH);
if (search == null) {
search = params.get(UserModel.USERNAME);
}
if (search == null) {
search = "";
}
return repository.searchUsers(search,
firstResult != null ? firstResult : 0,
maxResults != null ? maxResults : 100)
.stream()
.map(user -> new ExternalUserAdapter(session, realm, model, user));
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group,
Integer firstResult, Integer maxResults) {
// Not supported - groups are managed in Keycloak
return Stream.empty();
}
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm,
String attrName, String attrValue) {
// Not supported for external attributes
return Stream.empty();
}
// --- UserCountMethodsProvider ---
@Override
public int getUsersCount(RealmModel realm) {
return repository.getUserCount();
}
// --- CredentialInputValidator ---
@Override
public boolean supportsCredentialType(String credentialType) {
return PasswordCredentialModel.TYPE.equals(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
return supportsCredentialType(credentialType);
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
if (!supportsCredentialType(credentialInput.getType())) {
return false;
}
String username = user.getUsername();
String password = credentialInput.getChallengeResponse();
boolean valid = repository.validateCredentials(username, password);
if (valid) {
LOG.infof("Successful credential validation for user: %s", username);
} else {
LOG.warnf("Failed credential validation for user: %s", username);
}
return valid;
}
// --- UserStorageProvider ---
@Override
public void close() {
// Clean up resources if needed
}
}
Credential Migration Strategy
A common pattern is to migrate users to Keycloak’s credential store after their first successful login. This lets you gradually move off the legacy system:
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
if (!supportsCredentialType(credentialInput.getType())) {
return false;
}
String username = user.getUsername();
String password = credentialInput.getChallengeResponse();
boolean valid = repository.validateCredentials(username, password);
if (valid) {
// Migrate the credential to Keycloak's store
// Next login will use Keycloak's credential, not the external store
session.userCredentialManager().updateCredential(
realm, user,
UserCredentialModel.password(password, false)
);
LOG.infof("Migrated credentials for user: %s", username);
}
return valid;
}
After migration, Keycloak uses its own credential store for the user. The federation provider is still consulted for user lookups, but credential validation is handled by Keycloak.
Provider Factory
The factory creates provider instances and defines the configuration UI:
// src/main/java/com/example/federation/ExternalUserStorageProviderFactory.java
package com.example.federation;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.storage.UserStorageProviderFactory;
import java.util.List;
public class ExternalUserStorageProviderFactory
implements UserStorageProviderFactory<ExternalUserStorageProvider> {
public static final String PROVIDER_ID = "external-user-federation";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "Federates users from an external PostgreSQL database";
}
@Override
public ExternalUserStorageProvider create(KeycloakSession session, ComponentModel model) {
String jdbcUrl = model.get("jdbcUrl");
String dbUsername = model.get("dbUsername");
String dbPassword = model.get("dbPassword");
ExternalUserRepository repository =
new ExternalUserRepository(jdbcUrl, dbUsername, dbPassword);
return new ExternalUserStorageProvider(session, model, repository);
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name("jdbcUrl")
.label("JDBC URL")
.helpText("JDBC connection URL for the external user database. "
+ "Example: jdbc:postgresql://host:5432/userdb")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("jdbc:postgresql://localhost:5432/legacy_users")
.add()
.property()
.name("dbUsername")
.label("Database Username")
.helpText("Username for the external database connection")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("dbPassword")
.label("Database Password")
.helpText("Password for the external database connection")
.type(ProviderConfigProperty.PASSWORD)
.secret(true)
.add()
.build();
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm,
ComponentModel config)
throws ComponentValidationException {
String jdbcUrl = config.get("jdbcUrl");
String dbUsername = config.get("dbUsername");
String dbPassword = config.get("dbPassword");
if (jdbcUrl == null || jdbcUrl.isEmpty()) {
throw new ComponentValidationException("JDBC URL is required");
}
if (dbUsername == null || dbUsername.isEmpty()) {
throw new ComponentValidationException("Database username is required");
}
// Test the connection
try {
ExternalUserRepository repo =
new ExternalUserRepository(jdbcUrl, dbUsername, dbPassword);
repo.getUserCount(); // Simple connectivity test
} catch (Exception e) {
throw new ComponentValidationException(
"Cannot connect to external database: " + e.getMessage());
}
}
}
SPI Registration
Create the service file:
src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory
With contents:
com.example.federation.ExternalUserStorageProviderFactory
Caching Strategy
By default, Keycloak caches user lookups. For federation providers, you can control caching behavior:
Keycloak’s Built-in Cache
When configuring the federation provider in the admin console, you can set:
- Cache Policy: How long user data is cached
DEFAULT: Uses Keycloak’s default cache settingsEVICT_DAILY: Cache evicted once per dayEVICT_WEEKLY: Cache evicted once per weekMAX_LIFESPAN: Cache entries expire after a specified durationNO_CACHE: No caching (every lookup hits the external store)
For most use cases, MAX_LIFESPAN with a value of 5-15 minutes provides a good balance between performance and data freshness.
Cache Invalidation
If you need to invalidate a specific user’s cache (e.g., after a password change in the external system), you can call:
// In a custom REST endpoint or admin event listener
UserStorageUtil.userCache(session).evict(realm, user);
Lazy vs. Eager Loading
Lazy Loading (Recommended)
The provider shown above uses lazy loading: users are only loaded from the external store when Keycloak needs them (during login, admin console searches, etc.). This is the recommended approach because:
- No upfront synchronization required
- Works with external stores of any size
- No data duplication
- Changes in the external store are reflected on the next cache expiry
Eager Loading (Import)
For scenarios where you want to copy all users into Keycloak’s database, implement ImportedUserValidation:
public class ExternalUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
ImportedUserValidation,
CredentialInputValidator {
@Override
public UserModel validate(RealmModel realm, UserModel local) {
// Called when Keycloak has a cached/imported version of the user
// Return null to indicate the user no longer exists externally
ExternalUser externalUser = repository.findById(
StorageId.externalId(local.getId())
);
if (externalUser == null) {
return null; // User deleted from external store
}
// Update local user with external data
local.setEmail(externalUser.getEmail());
local.setFirstName(externalUser.getFirstName());
local.setLastName(externalUser.getLastName());
local.setEnabled(externalUser.isEnabled());
return local;
}
}
With import mode, Keycloak stores a copy of the user in its own database. The validate method is called periodically to sync changes from the external store.
Building and Deploying
mvn clean package
Deploy to Standalone Keycloak
cp target/keycloak-custom-user-federation-1.0.0.jar /opt/keycloak/providers/
/opt/keycloak/bin/kc.sh build
/opt/keycloak/bin/kc.sh start
Deploy in Kubernetes
Build a custom Keycloak image as described in our Kubernetes deployment guide:
FROM quay.io/keycloak/keycloak:26.0 as builder
COPY target/keycloak-custom-user-federation-1.0.0.jar /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
Configuring in the Admin Console
After deployment:
- Go to User Federation in the admin console
- Select your provider from the Add provider dropdown
- Fill in the connection details (JDBC URL, username, password)
- Set the cache policy
- Set the priority (lower number = higher priority, checked first)
- Click Save
Test by logging in with a user that exists in the external database.
Monitoring and Troubleshooting
Logging
Enable debug logging for your provider:
# keycloak.conf or via environment variable
log-level=com.example.federation:DEBUG
Common Issues
- User not found: Check that the username lookup query matches your database schema (case sensitivity matters).
- Password validation fails: Verify the hashing algorithm matches between your BCryptHelper and the external database.
- Connection errors: Ensure the JDBC URL is reachable from the Keycloak container and the PostgreSQL driver is included in the JAR.
- Performance issues: Enable caching and monitor query execution times. Add database indexes on the username and email columns.
Monitor authentication events through Keycloak’s audit logs to track federation-related login successes and failures. Use the JWT Token Analyzer to verify that federated users receive the correct token claims.
Conclusion
Custom user federation providers bridge the gap between legacy user stores and modern identity management. They enable gradual migration strategies, allowing users to continue logging in with their existing credentials while you build out Keycloak as your central identity platform. The Keycloak User Storage SPI documentation covers the full provider interface and lifecycle callbacks.
The key architectural decisions are:
- Lazy loading for most cases (simpler, works at any scale)
- Credential migration to move users to Keycloak’s store over time
- Appropriate caching to balance performance and data freshness
- Connection pooling in your repository for production workloads
For organizations that need user federation without managing Keycloak infrastructure, Skycloak’s managed hosting supports custom SPI providers including federation extensions. See our pricing for details, or consult the documentation for deployment instructions.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.