Keycloak SPI extensions for unique user attribute enforcement. Built for Keycloak 26.0.
Generic validator that ensures a user attribute value is unique across all users in the realm. Can be applied to any attribute in the User Profile configuration.
- Provider ID:
unique-attribute - Default error key:
error-attribute-not-unique
| Option | Type | Default | Description |
|---|---|---|---|
error-message |
string | error-attribute-not-unique |
Message key returned on duplicate. Override to use attribute-specific messages (e.g. error-display-name-already-exists). |
lookup-by-username |
boolean | false |
When true, checks uniqueness via username lookup on value.toLowerCase() instead of attribute search. Useful when paired with the attribute-sync listener for case-insensitive uniqueness. |
Attribute search (default) - queries users by attribute value using searchForUserByUserAttributeStream. Exact match.
Username lookup (lookup-by-username: true) - looks up value.toLowerCase() as a username. Enables case-insensitive uniqueness when paired with an event listener that syncs the attribute to username (e.g. attribute-sync).
Syncs a source user attribute to a target field (with optional transformation) whenever a user registers, updates their profile, or is updated by an admin. Optionally performs a reverse sync on registration only (e.g. copying the username to a display name attribute before the main sync runs).
- Provider ID:
attribute-sync - Triggers on:
REGISTER,UPDATE_PROFILEuser events andUPDATEadmin events onUSERresources - Default: syncs
display_name→usernamewithlowercasetransformation
Set via Keycloak SPI config (e.g. environment variables or CLI options):
Main sync (runs on REGISTER, UPDATE_PROFILE, and admin UPDATE):
| Option | Default | Description |
|---|---|---|
sourceAttribute |
display_name |
Source user attribute to read from |
targetField |
username |
Target: username, email, firstName, lastName, or attribute name |
transformation |
lowercase |
lowercase or none -- applied to the source value before writing to target |
On-register sync (runs on REGISTER only, before the main sync):
| Option | Default | Description |
|---|---|---|
onRegisterSourceField |
(none) | Source field to read: username, email, firstName, lastName, or attribute name. Disabled when null. |
onRegisterTargetAttribute |
(none) | Target user attribute to write to. Disabled when null. |
onRegisterTransformation |
none |
lowercase or none -- applied to the source value before writing to target |
Both onRegisterSourceField and onRegisterTargetAttribute must be set for the on-register sync to activate. When disabled (default), only the main sync runs.
Registration forms often show only a username field. The on-register sync copies the original (mixed-case) username into a display name attribute before the main sync lowercases it:
- User registers with username
JohnDoe - On-register sync:
username→display_name(no transformation) --display_name = "JohnDoe" - Main sync:
display_name→username(lowercase) --username = "johndoe"
Result: username = "johndoe", display_name = "JohnDoe"
Environment variables for this setup:
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ON_REGISTER_SOURCE_FIELD=username
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ON_REGISTER_TARGET_ATTRIBUTE=display_name
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ON_REGISTER_TRANSFORMATION=nonePre-save validator that checks whether syncing a source attribute to a target field would cause a conflict. Mirrors the transformation logic of attribute-sync and validates before the profile is saved, preventing silent sync failures.
- Provider ID:
sync-target-validator - Default error key:
error-sync-target-conflict
| Option | Type | Default | Description |
|---|---|---|---|
target-field |
string | username |
Target field: username, email, firstName, lastName, or an attribute name |
transformation |
string | lowercase |
lowercase or none - must match sync listener config |
error-message |
string | error-sync-target-conflict |
Custom error message key |
| Target | Check | Notes |
|---|---|---|
username |
getUserByUsername(realm, transformed) |
Fails if a different user owns it |
email |
getUserByEmail(realm, transformed) |
Fails if a different user owns it |
firstName / lastName |
no-op | No uniqueness constraints |
| (other) | searchForUserByUserAttributeStream |
Fails if a different user owns it |
Error message translations are bundled in the JAR via theme-resources/messages/ and automatically merged into the active theme - no separate theme installation needed.
| Key | EN | ES | PL |
|---|---|---|---|
error-attribute-not-unique |
This value is already taken. Please choose a different one. | Este valor ya está en uso. Por favor, elige otro. | Ta wartość jest już zajęta. Wybierz inną. |
error-display-name-already-exists |
Display name is already taken. Please choose a different one. | El nombre para mostrar ya está en uso. Por favor, elige otro. | Nazwa wyświetlana jest już zajęta. Wybierz inną. |
error-sync-target-conflict |
This value conflicts with an existing {0}. Please choose a different one. | Este valor entra en conflicto con un {0} existente. Por favor, elige otro. | Ta wartość koliduje z istniejącym {0}. Wybierz inną. |
- Java 17+ (compile time)
- Maven 3.9+ (compile time)
- Keycloak 26.0 (runtime)
Build the JAR using Maven in Docker (no local Maven installation required):
docker run --rm -v "$(pwd)":/build -w /build maven:3.9-eclipse-temurin-17 mvn clean packageOutput: validator/target/keycloak-extensions-spi-1.0.0.jar
Copy the JAR to Keycloak's providers/ directory and rebuild:
cp validator/target/keycloak-extensions-spi-1.0.0.jar /opt/keycloak/providers/
/opt/keycloak/bin/kc.sh buildBuild the SPI and package it into a Keycloak image in a single Dockerfile:
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY . .
RUN mvn clean package -q
FROM quay.io/keycloak/keycloak:26.0
COPY --from=builder /build/validator/target/keycloak-extensions-spi-1.0.0.jar /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh buildservices:
postgres:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
volumes:
- pgdata:/var/lib/postgresql/data
keycloak:
build: .
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_HOSTNAME: localhost
KC_HTTP_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
# Enable the attribute-sync event listener
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ENABLED: "true"
ports:
- "8080:8080"
depends_on:
- postgres
volumes:
pgdata:Build and start:
docker compose up --buildAfter deploying the JAR, configure the providers in the Keycloak Admin Console.
Go to Realm Settings > Events > Event Listeners and add attribute-sync.
By default it syncs display_name → username with lowercase transformation. To customize, set environment variables:
# Main sync: change source attribute
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_SOURCE_ATTRIBUTE=my_attr
# Main sync: change target field (username, email, firstName, lastName, or any attribute)
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_TARGET_FIELD=email
# Main sync: change transformation (lowercase or none)
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_TRANSFORMATION=none
# On-register sync (optional): copy username to display_name on registration
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ON_REGISTER_SOURCE_FIELD=username
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ON_REGISTER_TARGET_ATTRIBUTE=display_name
KC_SPI_EVENTS_LISTENER_ATTRIBUTE_SYNC_ON_REGISTER_TRANSFORMATION=noneGo to Realm Settings > User Profile, select the source attribute (e.g. display_name), and add validators:
sync-target-validator - prevents saving if the sync would cause a conflict:
| Option | Value | Notes |
|---|---|---|
target-field |
username |
Must match the listener's targetField |
transformation |
lowercase |
Must match the listener's transformation |
error-message |
error-sync-target-conflict |
Optional, customize error key |
unique-attribute - prevents duplicate attribute values:
| Option | Value | Notes |
|---|---|---|
lookup-by-username |
true |
Use true when paired with attribute-sync targeting username |
error-message |
error-display-name-already-exists |
Optional, customize error key |
All three providers working together for case-insensitive unique display names:
- Enable
attribute-syncevent listener (default config:display_name→username,lowercase) - On
display_nameattribute, add validators:sync-target-validatorwithtarget-field: username,transformation: lowercaseunique-attributewithlookup-by-username: true,error-message: error-display-name-already-exists
The sync-target-validator rejects values that would cause a username collision before the save, while unique-attribute handles the attribute-level uniqueness check.
Simpler setup - syncs and validates, but won't catch conflicts before save:
- Enable
attribute-syncevent listener - On
display_name, addunique-attributewithlookup-by-username: true
For simple exact-match uniqueness on any attribute without syncing:
- On the attribute, add
unique-attributevalidator - Optionally set a custom
error-message
No event listener needed - the validator queries the attribute directly.
User submits form
|
+-- sync-target-validator: Would lowercased value conflict with another username?
| +-- YES -> reject with error (before save)
|
+-- unique-attribute: Is this attribute value already taken?
| +-- YES -> reject with error (before save)
|
+-- Profile saved
|
+-- attribute-sync (event listener):
+-- [REGISTER only] On-register sync: username -> display_name (if configured)
+-- Main sync: display_name.toLowerCase() -> username
- User registers or updates profile
- Validator calls
searchForUserByUserAttributeStream(realm, attribute, value) - If another user has the same value, validation fails
- User registers or updates profile with a
display_name - Validator looks up
value.toLowerCase()as a username - If another user already has that username, validation fails
The username lookup mode gives case-insensitive uniqueness without requiring a custom database index.
keycloak-extensions/
├── pom.xml # Maven parent POM
└── validator/
├── pom.xml
└── src/main/
├── java/network/undefined/keycloak/validator/
│ ├── UniqueAttributeValidatorFactory.java
│ ├── SyncTargetValidatorFactory.java
│ └── AttributeSyncListenerFactory.java
└── resources/
├── META-INF/services/
│ ├── org.keycloak.validate.ValidatorFactory
│ └── org.keycloak.events.EventListenerProviderFactory
└── theme-resources/messages/
├── messages_en.properties
├── messages_es.properties
└── messages_pl.properties