A working guide to building your own Keycloak image — custom login UI, a custom
authenticator that consults an internal service, and an event listener that pushes
events out — packaged as one reproducible Docker image.
Everything here is from a Keycloak 24.x deployment on Java 17, built with
Docker and Make. The general shape applies to 22–26; the exact class names and
container paths are version-specific and called out where they matter.
What you get at the end
| Customization | Mechanism | Ships as |
|---|---|---|
| Branded login / email / account pages | FreeMarker theme + Tailwind | theme.jar |
| Reworded or localized UI strings | Theme message bundle | same jar |
| Extra logic in the login flow |
Authenticator SPI |
provider.jar |
| Disabled accounts revalidated by an internal service | Custom UsernamePasswordForm
|
same jar |
| Auth events pushed to your backend |
EventListenerProvider SPI |
same jar |
The two mechanisms are worth separating in your head up front: a theme changes
what the user sees; an SPI changes what Keycloak does. Many "we need a custom
Keycloak" requests turn out to be theme-only, which is far cheaper. Reach for an
SPI when you need a decision Keycloak cannot express in its own configuration.
1. Initialize the project
One repository, one image. Sources for each customization live beside the
Dockerfile that bakes them in.
keycloak/
├── Dockerfile # assembles the image
├── Makefile # build-theme, build-spi, build, push
├── plugins/ # built JARs — the only thing COPYed into the image
│ ├── mytheme.jar
│ ├── my-keycloak-spi-1.0.0.jar
│ └── third-party-*.jar # e.g. metrics, mail whitelisting
├── spi/ # Java SPI sources (Maven)
│ ├── pom.xml
│ └── src/main/java/...
├── themes/
│ └── mytheme/ # theme sources (Node + FreeMarker)
│ ├── theme/mytheme/ # the part that ends up in the jar
│ ├── META-INF/keycloak-themes.json
│ └── package.json
└── docs/
plugins/ holds build outputs that are committed to git. That is deliberate,
and it is the one convention worth explaining to reviewers: the Docker build stays
a single COPY with no toolchain in it, so anyone can rebuild the image without
Node or Maven installed. The cost is that a stale JAR is invisible — see the
failure mode below.
Prerequisites
Only Docker and Make on the host. Both toolchains run in containers, so nobody
needs a matching JDK or Node version locally:
docker --version # Buildx required for `make build`
make --version
Scaffold it
mkdir -p keycloak/{plugins,spi/src/main/{java,resources/META-INF/services},themes,docs}
cd keycloak && git init
printf 'auth.tar.gz\n' > .dockerignore
For the theme, start from an existing open-source Keycloak theme rather than from
nothing — the FreeMarker templates have a lot of implicit contract with the server,
and a fork gives you every page already wired. Keywind (Tailwind-based) is a good
starting point; so is a copy of Keycloak's own base theme.
2. The Dockerfile
Use a public base image. Two families exist and they are not
interchangeable at runtime — this is the single biggest source of wasted hours
in this whole exercise.
Option A — official image (recommended)
# ---- stage 1: augment the server with our providers ----
FROM quay.io/keycloak/keycloak:24.0.4 AS builder
COPY plugins/*.jar /opt/keycloak/providers/
# Bake providers + theme into an optimized server image.
RUN /opt/keycloak/bin/kc.sh build
# ---- stage 2: runtime ----
FROM quay.io/keycloak/keycloak:24.0.4
ARG GIT_COMMIT=unknown
ARG GIT_BRANCH=unknown
ARG BUILD_DATE=unknown
ARG VERSION=1.0.0
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
git.branch="${GIT_BRANCH}"
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
kc.sh build is the step people miss. Keycloak augments itself at build time;
dropping a JAR into a running container and restarting does not reliably
register a new provider in an optimized image. Running build in a stage and
copying the result keeps startup fast and the provider registration durable.
Option B — Bitnami-convention image
FROM docker.io/bitnami/keycloak:24.0.4
COPY plugins /opt/bitnami/keycloak/providers
# No ENTRYPOINT/CMD override — see the warning below.
Simpler, because Bitnami's setup.sh runs kc.sh build for you on first start.
Two things to know:
- Verify the tag is still published before you depend on it. Bitnami has been
relocating older Docker Hub tags; if
bitnami/keycloak:24.0.4404s, check thebitnamilegacynamespace or pin a tag you have mirrored yourself. -
Never set
command:on this image. Its entrypoint ends in a bareexec "$@", so acommand: start-devis looked up as a binary and the container dies withexec: start-dev: not found. Worse, its setup script only runs when the command containsrun.sh— so overriding the command also skips database configuration, admin-user creation, and the build step. Dev vs. production is an environment variable (KEYCLOAK_PRODUCTION), not an argument.
Environment variables are not portable between the two
If you switch families, every database variable changes name. Bitnami's startup
script waits for the database in Bash, before the JVM launches, reading only
its own variable names — so Keycloak's config precedence rules never get a chance
to apply, and an unrecognized KC_DB_URL is silently ignored while the built-in
default host is used instead.
| Purpose | Official image | Bitnami image |
|---|---|---|
| DB vendor | KC_DB=postgres |
KEYCLOAK_DATABASE_VENDOR=postgresql |
| DB host | (part of KC_DB_URL) |
KEYCLOAK_DATABASE_HOST |
| DB name | (part of KC_DB_URL) |
KEYCLOAK_DATABASE_NAME |
| DB user / password |
KC_DB_USERNAME / KC_DB_PASSWORD
|
KEYCLOAK_DATABASE_USER / KEYCLOAK_DATABASE_PASSWORD
|
| Admin bootstrap |
KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD
|
same |
| Dev vs prod |
start-dev vs start --optimized
|
`KEYCLOAK_PRODUCTION=false\ |
Raw {% raw %}kc.sh flags |
appended to the command | KEYCLOAK_EXTRA_ARGS |
| Providers path | /opt/keycloak/providers |
/opt/bitnami/keycloak/providers |
To find the full set for any Bitnami tag, read it out of the image instead of
guessing:
docker run --rm --entrypoint cat <image> /opt/bitnami/scripts/keycloak-env.sh
Watch for provider collisions
Base images often already ship popular community providers. Two versions of the
same provider in providers/ produces a split-package warning at startup and
which one wins is not under your control:
docker run --rm --entrypoint ls <your-image> /opt/keycloak/providers
If a JAR you are adding is already there, drop yours and use the bundled one — or
pin deliberately, but knowingly.
3. The build pipeline
A Makefile gives each artifact its own target, so a theme-only change does not
rebuild Java and vice versa.
IMAGE_NAME = myorg/keycloak
IMAGE_TAG ?= 1.0.0
FULL_IMAGE = $(IMAGE_NAME):$(IMAGE_TAG)
PLATFORM ?= linux/amd64
SKIP_PUSH ?= 1
SPI_JAR = my-keycloak-spi-1.0.0.jar
THEME_JAR = mytheme.jar
PLUGINS_DIR = plugins
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
# Maven in a container: no local JDK needed, ~/.m2 cached across runs.
build-spi:
docker run --rm \
-v "$(CURDIR)/spi":/app -v "$(HOME)/.m2":/root/.m2 -w /app \
maven:3.8-openjdk-17 mvn clean package -DskipTests
cp spi/target/$(SPI_JAR) $(PLUGINS_DIR)/$(SPI_JAR)
# Node in a container, exporting only the jar via a scratch stage.
build-theme:
DOCKER_BUILDKIT=1 docker build -f themes/mytheme/Dockerfile.build \
--output type=local,dest=$(CURDIR)/themes/mytheme/out/ \
themes/mytheme/
cp themes/mytheme/out/$(THEME_JAR) $(PLUGINS_DIR)/$(THEME_JAR)
build:
docker buildx build --platform $(PLATFORM) \
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
--build-arg GIT_BRANCH=$(GIT_BRANCH) \
--build-arg BUILD_DATE=$(BUILD_DATE) \
--build-arg VERSION=$(IMAGE_TAG) \
--load -t $(FULL_IMAGE) .
@if [ "$(SKIP_PUSH)" = "0" ]; then docker push $(FULL_IMAGE); fi
SKIP_PUSH=1 by default: pushing should be something you ask for, not something
that happens because you typed make.
The theme builder uses FROM scratch as its final stage so BuildKit's
--output type=local writes just the jar to the host:
# themes/mytheme/Dockerfile.build
FROM node:20 AS builder
RUN npm install -g pnpm@8
WORKDIR /assets
COPY . /assets
RUN pnpm install && pnpm build && pnpm build:jar
FROM scratch
COPY --from=builder /assets/out .
The stale-JAR trap
make build-spi must succeed before make build. If Maven fails, plugins/
still holds the previous JAR and the image builds cleanly — shipping without
your change. It presents as "my code isn't running", and you will look for the bug
in your code. Chain them so a failure stops the line:
make build-spi && make build-theme && make build
Then confirm at startup (section 7) rather than assuming.
4. Customize the UI with a theme
Anatomy of a theme jar
A Keycloak theme is a jar with exactly two things in it:
META-INF/keycloak-themes.json # declares the theme and which types it provides
theme/mytheme/
├── login/
│ ├── theme.properties
│ ├── login.ftl login-reset-password.ftl register.ftl ...
│ ├── template.ftl # the shared page shell
│ ├── components/ # your own macros (optional)
│ ├── messages/messages_en.properties
│ └── resources/ # css, js, images served to the browser
├── email/ account/ admin/ welcome/
{
"themes": [
{ "name": "mytheme", "types": ["account", "admin", "email", "login", "welcome"] }
]
}
Packaging is just a zip — no Maven needed:
// scripts/build.ts (run with vite-node / tsx)
import archiver from 'archiver';
import { createWriteStream, existsSync, mkdirSync } from 'fs';
const dir = 'out';
!existsSync(dir) && mkdirSync(dir);
const archive = archiver('zip');
archive.pipe(createWriteStream(`${dir}/mytheme.jar`));
archive.directory('META-INF', 'META-INF');
archive.directory('theme', 'theme');
archive.finalize();
Inherit, don't rewrite
theme.properties is where you choose how much you own:
# theme/mytheme/login/theme.properties
parent=base # inherit every .ftl and message you don't override
styles=dist/index.css # injected into <head> by the base template
scripts=dist/index.js
MY_PRODUCT_NAME=${env.MY_PRODUCT_NAME}
-
parent=base— bare templates, no Keycloak styling. The right choice when you are writing your own CSS (e.g. Tailwind) and want full control of the markup. -
parent=keycloak/parent=keycloak.v2— inherit Keycloak's own look and patch it. Right for theaccountandadminthemes, where rewriting is rarely worth it.
${env.VAR} reads a container environment variable at render time. That is how you
get one image serving several brands: same jar, different MY_PRODUCT_NAME per
environment. Reference it in a template as ${properties.MY_PRODUCT_NAME}.
Styling with Tailwind
Vite compiles into the theme's resources/dist, which Keycloak serves as static
assets:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
input: ['src/index.ts'],
output: {
dir: 'theme/mytheme/login/resources/dist',
entryFileNames: '[name].js',
assetFileNames: '[name][extname]',
},
},
},
});
Tailwind must be told to scan .ftl files or it will purge every class you use:
// tailwind.config.ts
export default {
content: ['./theme/**/*.ftl'],
theme: { extend: { colors: { primary: { /* your palette */ } } } },
plugins: [require('@tailwindcss/forms')],
};
Build reusable macros
Rather than repeating markup across twenty templates, factor components out. An
input macro that also handles the show/hide password toggle (Alpine.js here):
<#-- components/atoms/input.ftl -->
<#macro kw name="" label="" type="text" required=true invalid=false message="" rest...>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1" for="${name}">
${label}<#if required><span class="text-red-600">*</span></#if>
</label>
<#if type == "password">
<div class="relative" x-data="{ show: false }">
<input id="${name}" name="${name}" :type="show ? 'text' : 'password'"
aria-invalid="${invalid?c}" class="..."
<#list rest as k, v>${k}="${v}"</#list>>
<button type="button" @click="show = !show" aria-controls="${name}"
:aria-expanded="show">…</button>
</div>
<#else>
<input id="${name}" name="${name}" type="${type}" aria-invalid="${invalid?c}"
class="..." <#list rest as k, v>${k}="${v}"</#list>>
</#if>
<#if invalid && message?has_content>
<div class="mt-2 text-red-600 text-sm">${message?no_esc}</div>
</#if>
</div>
</#macro>
Then every page is short and consistent:
<@input.kw name="username" label=usernameLabel autofocus=true
invalid=messagesPerField.existsError("username")
message=kcSanitize(messagesPerField.get("username")) />
Two FreeMarker rules that are security-relevant, not stylistic:
-
${...}escapes HTML by default — leave it that way. - Any value you must render as HTML goes through
kcSanitize(...)?no_esc, never?no_escalone.kcSanitizeis Keycloak's allow-list sanitizer; skipping it on user- or realm-supplied text is an XSS hole on your login page.
Worked example: rewording a message
The smallest useful customization, and a good illustration of how the message
bundle layers. Keycloak's base theme ships:
emailInstruction=Enter your username or email address and we will send you instructions on how to create a new password.
If your realm only allows email login, that sentence is wrong. You do not edit
the template — login-reset-password.ftl already renders the key:
<#elseif section="info">
${msg("emailInstruction")}
Override just the key in your theme's bundle:
# theme/mytheme/login/messages/messages_en.properties
# Password reset messages
emailInstruction=Enter email address and we will send you instructions on how to create a new password.
Keycloak resolves msg(...) through your theme first, then the parent chain, so
one line changes the page and nothing else is touched. The same file is where
every other string override lives — loginTitle, field labels, validation text.
Add messages_<locale>.properties siblings for other languages.
Two things that bite here:
- A typo'd key fails silently. There is no error and no warning — you just get the parent's text back, which looks exactly like "my change didn't deploy". Which is precisely why the next section matters.
- End the file with a newline. Not a functional requirement, but without it every future edit shows the previous last line as changed, and the diff noise buries the actual change under review.
Test templates without starting Keycloak
FreeMarker is renderable in a plain JUnit test. Keycloak publishes the theme
support classes, so you can assert on real rendered HTML in about a second —
instead of rebuilding an image and clicking through a browser:
@Test
public void shouldRenderEmailOnlyPasswordResetInstruction() throws Exception {
Configuration configuration = createFreeMarkerConfiguration();
Template template = configuration.getTemplate("login-reset-password.ftl");
String pageText = formatHtml(renderTemplate(template)).text(); // jsoup
assertTrue(pageText.contains(
"Enter email address and we will send you instructions on how to create a new password."));
assertFalse(pageText.contains("username or email"));
}
The assertFalse is the load-bearing half: it proves your override actually won,
rather than the parent's string leaking through a typo'd key.
Wire the configuration so the loader reads the base bundle and then your
theme's, mirroring runtime precedence:
Properties properties = new Properties();
// 1. base messages from the keycloak-themes jar on the test classpath
// 2. then your theme's, which overwrite matching keys
Path themeMessages = Path.of(THEME_PATH, "messages", "messages_en.properties");
try (InputStream in = Files.newInputStream(themeMessages)) {
properties.load(in);
}
Test dependencies: freemarker, jsoup, junit-jupiter, plus Keycloak's
keycloak-themes, keycloak-services, and keycloak-server-spi-private for
kcSanitize and the MessagesPerFieldBean / MessageFormatterMethod beans the
templates expect in scope.
Activate it
Realm settings → Themes, pick your theme per type (Login, Account, Email,
Admin), Save. For a fresh environment, set it in the realm import JSON:
{ "realm": "myrealm", "loginTheme": "mytheme", "emailTheme": "mytheme" }
During development, skip the rebuild loop entirely — mount the theme directory and
turn caching off:
volumes:
- ./themes/mytheme/theme:/opt/keycloak/themes
environment:
KC_SPI_THEME_STATIC_MAX_AGE: "-1"
KC_SPI_THEME_CACHE_THEMES: "false"
KC_SPI_THEME_CACHE_TEMPLATES: "false"
Then a .ftl edit is visible on refresh. Build the jar only when you are done.
5. Customize the login flow with an SPI
Now the server-behavior half. The example: an account disabled in Keycloak
should be revalidated against an internal service before we refuse the login —
and, if the service says the account is fine, optionally re-enabled so the user
gets in.
Project setup
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<keycloak.version>24.0.0</keycloak.version>
</properties>
<dependencies>
<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-services</artifactId>
<version>${keycloak.version}</version><scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId>
<version>2.16.1</version><scope>provided</scope>
</dependency>
</dependencies>
Every Keycloak dependency is provided. So is Jackson, and so is
jboss-logging — the server already has them. Bundling your own copy either
inflates the jar harmlessly or breaks classloading in ways that are painful to
diagnose. Your jar should contain your classes and nothing else.
Match keycloak.version to the server you deploy against. Internal SPI classes
are explicitly allowed to change between minor versions.
Extend the built-in form, don't replace it
The instinct is to add a new step before the login form. Don't. A standalone
step that looks up the user and calls out would leak "this account exists, and
here is why it is blocked" to anyone who can type a username into a public form.
Instead, subclass UsernamePasswordForm and override the one method where
Keycloak decides an account is disabled:
public class DisabledUserAuthenticator extends UsernamePasswordForm {
@Override
public boolean enabledUser(AuthenticationFlowContext context, UserModel user) {
// Brute-force protection stays ahead of any outbound call.
if (isDisabledByBruteForce(context, user)) {
return false;
}
if (user.isEnabled()) {
return true; // normal path — the internal service is never called
}
Verdict verdict = askInternalService(context, user);
boolean reenable = isReenableEnabled(context);
if (verdict.valid && reenable) {
user.setEnabled(true);
context.getEvent().user(user).detail(Details.REASON, "revalidated");
log.infof("user=%s ALLOWED, account re-enabled", user.getUsername());
return true;
}
context.getEvent().user(user);
context.getEvent().error(Errors.USER_DISABLED);
context.forceChallenge(challenge(context,
verdict.reason != null ? verdict.reason : Messages.ACCOUNT_DISABLED));
return false;
}
}
Three properties come free from that choice, and all three are load-bearing:
-
The password is verified first. The base class runs
validatePassword(...)beforeenabledUser(...), so the internal service is only ever called for a caller who already proved the password. No username-enumeration oracle. - Brute-force protection stays in front, exactly as in the base class.
- Username-or-email resolution is inherited, so realms with "Login with email" enabled keep working without you reimplementing lookup.
Re-enabling is the only way to admit a disabled user. Keycloak's
AuthenticationProcessor.validateUser()re-checksuser.isEnabled()at several
points after the flow completes. "Allow this one login but leave the account
disabled" is not reachable from an authenticator — it requires flipping the flag.
Design around that, or you will spend a day proving it to yourself.
Call the internal service, and fail closed
private Verdict askInternalService(AuthenticationFlowContext context, UserModel user) {
String url = buildUrl(configValue(context, CONFIG_URL, DEFAULT_URL), user.getId());
if (url == null) {
return Verdict.invalid(null);
}
try {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(timeoutSeconds(context)))
.GET();
String credential = configValue(context, CONFIG_AUTH_HEADER, null);
if (credential != null && !credential.isBlank()) {
String headerName = configValue(context, CONFIG_AUTH_HEADER_NAME, DEFAULT_AUTH_HEADER_NAME);
builder.header(headerName.trim(), credential);
}
long started = System.currentTimeMillis();
HttpResponse<String> response =
httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
log.infof("GET %s -> status=%d took=%dms body=%s",
url, response.statusCode(), System.currentTimeMillis() - started,
truncate(response.body()));
return response.statusCode() == 200 ? parse(response.body()) : Verdict.invalid(null);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Verdict.invalid(null);
} catch (Exception e) {
// Fail closed: any transport or parse failure leaves the account disabled.
log.errorf(e, "check FAILED for user %s at %s (%s)",
user.getUsername(), url, e.getClass().getSimpleName());
return Verdict.invalid(null);
}
}
The contract, kept deliberately small:
GET {url}?userId=<keycloak-user-id> → 200 {"valid": true|false, "reason": "..."}
Rules worth encoding rather than documenting:
-
A missing
validfield counts as invalid. Otherwise a proxy error page returned with status 200 becomes an authentication bypass.
if (root.path("valid").isMissingNode()) {
log.warn("response has no 'valid' field, so it counts as invalid");
}
boolean valid = root.path("valid").asBoolean(false);
- Non-200, timeout, unparseable body → stay disabled. Availability of your internal service must never become a way in.
-
Truncate
reasonbefore rendering it (200 chars here). It is remote text headed for a public page. -
Keep
reasoncategory-level. The login page is public. "Your account is inactive." is fine; anything naming a compliance, billing, or screening outcome is an information leak to whoever is at the keyboard. - The timeout blocks the login request. Keep it at a few seconds. There is no "slow but eventually correct" here — there is a user watching a spinner.
The factory: registration and configuration
public class DisabledUserAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "internal-disabled-user-checker";
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
private volatile HttpClient httpClient;
private volatile DisabledUserAuthenticator authenticator;
@Override
public void init(Config.Scope config) {
// One client per provider lifecycle, built once configuration is available.
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.getInt("connectTimeoutSeconds", 5)))
.build();
this.authenticator = new DisabledUserAuthenticator(httpClient);
}
@Override public Authenticator create(KeycloakSession session) { return authenticator; }
@Override public String getId() { return PROVIDER_ID; }
@Override public String getDisplayType() { return "Internal Username Password Form"; }
@Override public boolean isConfigurable() { return true; }
@Override public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty url = new ProviderConfigProperty();
url.setName("check.url");
url.setLabel("Check URL");
url.setType(ProviderConfigProperty.STRING_TYPE);
url.setDefaultValue(DEFAULT_URL);
url.setHelpText("Queried when a disabled user submits correct credentials. "
+ "'userId' is appended as a query parameter.");
ProviderConfigProperty credential = new ProviderConfigProperty();
credential.setName("auth.header");
credential.setLabel("Auth header value");
credential.setType(ProviderConfigProperty.PASSWORD); // masked in the console
/* … timeout (STRING_TYPE), reenable (BOOLEAN_TYPE), header name … */
return Arrays.asList(url, timeout, reenable, headerName, credential);
}
}
Points that pay for themselves:
-
Build the
HttpClientininit(), not per request and not in a static initializer. One client per provider lifecycle; on Java 17HttpClientis notCloseable, soclose()has nothing to release. -
ProviderConfigProperty.PASSWORDmasks the value in the admin console. Credentials belong in flow config, not in the code or the image. -
Restricting
getRequirementChoices()toREQUIREDremoves a whole class of misconfiguration. AnALTERNATIVElogin form is almost never what anyone means. -
Default the dangerous switch to off.
reenabledefaults tofalse: re-enabling accounts on a remote system's say-so should be a decision, not an accident. Ship it off, watch the logs, then turn it on. -
getId()is permanent. It is the string stored in every flow row that references the provider. RenaminggetDisplayType()is cosmetic and safe; changinggetId()breaks every flow already using it.
Registration: one file per SPI interface
src/main/resources/META-INF/services/
├── org.keycloak.authentication.AuthenticatorFactory → com.example.keycloak.DisabledUserAuthenticatorFactory
└── org.keycloak.events.EventListenerProviderFactory → com.example.keycloak.EventListenerProviderFactory
The file name is the interface name; each line is one implementation class.
This is worth being pedantic about: listing an AuthenticatorFactory inside the
EventListenerProviderFactory file makes ServiceLoader throw
ServiceConfigurationError: not a subtype, which aborts the whole enumeration
— taking your previously working event listener down with it. One wrong line in
one file silently disables an unrelated, correct provider.
6. Wire the authenticator into the browser flow
Installing the jar makes the authenticator available. It does nothing until a
flow uses it. Duplicate the built-in flow; never edit it — the copy is your
rollback.
-
Authentication → Flows →
browser→ ⋮ → Duplicate, name itbrowser-custom. - Open it. In the
formssubflow, delete Username Password Form. - On the
formsrow: + → Add step → pick your display name → Add. - Set it Required, drag it above the conditional-OTP subflow.
- ⚙ gear → fill in URL, timeout, header name, credential → Save.
- ⋮ → Bind flow → Browser flow → Save.
Add step, not sub-flow
The single easiest mistake here, and it fails in a confusing way. "Add sub-flow"
creates an empty container named after your authenticator; "Add step"
attaches the authenticator itself. An empty REQUIRED sub-flow makes the flow
complete with no challenge and no authenticated user:KC-SERVICES0013: Failed authentication: org.keycloak.authentication.AuthenticationFlowExceptionBecause you also deleted the real Username Password Form, nobody in that realm
can log in — not just disabled users. Verify before you test in a browser.
Script it instead
Console clicking does not survive a rebuilt environment. The admin REST API does,
and the script becomes your runbook. Make it idempotent (reuse and update
rather than duplicate) and give it --verify and --rollback modes:
./scripts/setup-auth.sh # create/duplicate flow, add step, configure, bind
./scripts/setup-auth.sh --verify # report state, change nothing
./scripts/setup-auth.sh --rollback # rebind the stock browser flow
Sketch of the core, with curl + jq:
TOKEN=$(curl -s -d "client_id=admin-cli" -d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASSWORD" -d "grant_type=password" \
"$KC_URL/realms/$ADMIN_REALM/protocol/openid-connect/token" | jq -r .access_token)
api() { curl -sS -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" "$@"; }
# 1. copy the stock browser flow
api -X POST "$KC_URL/admin/realms/$REALM/authentication/flows/browser/copy" \
-d "{\"newName\":\"$FLOW_NAME\"}"
# 2. add the execution to the 'forms' subflow, set REQUIRED, attach config,
# reorder above conditional OTP, then:
# 3. bind it
api -X PUT "$KC_URL/admin/realms/$REALM" -d "{\"browserFlow\":\"$FLOW_NAME\"}"
Never default a real admin password inside the script. Read it from the
environment and let a flag override.
7. Push events out with an event listener
The other common SPI: react to what happens in Keycloak. An EventListenerProvider
sees every user and admin event.
public class MyEventListenerProvider implements EventListenerProvider {
@Override
public void onEvent(Event event) {
if (!enabled || event.getType() != EventType.VERIFY_EMAIL) {
return; // filter narrowly and early
}
ObjectNode payload = objectMapper.createObjectNode();
payload.put("event_type", event.getType().toString());
payload.put("timestamp", Instant.ofEpochMilli(event.getTime()).toString());
// Events carry ids, not profiles. Enrich from the session if you need fields.
RealmModel realm = session.realms().getRealm(event.getRealmId());
UserModel user = session.users().getUserById(realm, event.getUserId());
/* … copy the attributes your consumer needs … */
sendWebhookAsync(payload); // never block the request thread
}
@Override
public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) { }
}
Differences from the authenticator that matter:
-
Never block. An authenticator's HTTP call is synchronous because the decision
depends on it. A listener's does not — use
sendAsync/CompletableFutureand let a failure be a log line, not a failed login. -
Filter on
EventTypefirst. A busy realm emits a lot of events, and an unfiltered listener is a load generator pointed at your own backend. -
Events are thin. They carry
userIdandrealmId, so enrich fromsession.users()when the consumer needs email or attributes. -
Config comes from the environment (
System.getenv), not flow config — listeners have no per-execution config UI. Keep a kill switch (..._ENABLED).
Enable it per realm: Realm settings → Events → Event listeners, add your
provider id. A provider that loads but is not listed there simply never runs.
8. Verify it actually loaded
Do this before debugging anything else. It takes ten seconds and rules out the
stale-jar trap.
docker logs <keycloak-container> | grep KC-SERVICES0047
KC-SERVICES0047: internal-disabled-user-checker (com.example.keycloak.DisabledUserAuthenticatorFactory)
is implementing the internal SPI authenticator
KC-SERVICES0047 is a routine "internal SPI may change without notice" notice,
not an error. If the line is missing, your jar is stale or the build failed —
fix that before touching flows.
Is it a real step, or an empty sub-flow? The database answers faster than the UI:
docker exec <db-container> psql -U keycloak -d keycloak -t -A -c \
"SELECT count(*) FROM authentication_execution
WHERE authenticator='internal-disabled-user-checker';"
Must return 1. 0 means you have the empty sub-flow.
Is the flow bound?
docker exec <db-container> psql -U keycloak -d keycloak -t -A -c \
"SELECT r.name||' -> '||f.alias FROM realm r
JOIN authentication_flow f ON f.id=r.browser_flow;"
Can Keycloak reach the internal service from inside the container?
docker exec <keycloak-container> curl -s -H "x-api-key: $API_KEY" \
"http://internal-service:8080/api/v1/internal/users/check-disabled?userId=test-123"
Watch it run — grep the class name, not your project name, or you will catch
the unrelated event listener's lines too:
docker logs -f <keycloak-container> | grep DisabledUserAuthenticator
A disabled user submitting the correct password produces two lines:
GET http://.../check-disabled?userId=4bb0336a-… -> status=200 took=66ms body={"valid":false,"reason":"…"}
user=alice REFUSED - Your account is inactive.
Nothing is logged for an enabled user or a wrong password. By design — the
check only runs for a disabled user who has already proven their password.
Troubleshooting
| Symptom | Cause |
|---|---|
| No authenticator log lines at all | Step never added, or added as a sub-flow. Run the count query. |
KC-SERVICES0047 missing at startup |
Stale jar — the SPI build failed, or the image build was skipped. |
AuthenticationFlowException and nobody can log in |
Empty sub-flow where the login form used to be. |
ConnectException on the outbound call |
Wrong hostname from inside the container (see below). |
status=401 |
Missing or malformed credential header. Check for an accidental Bearer prefix. |
Response has no valid field |
The service returned a different shape — or a proxy error page. |
| Service says valid but login still refused | The re-enable switch is off. The log says so explicitly. |
| Theme change not visible | Theme cache. Set the KC_SPI_THEME_CACHE_* vars, or rebuild the jar. |
| Overridden message still shows the old text | Typo'd key (you got the parent's string) or a missing trailing newline. |
Hostnames from inside a container. host.docker.internal reaches the host
machine — use it when the internal service runs natively while Keycloak runs in
Docker. It is a Docker-Desktop-only name: it does not exist on Linux servers or in
Kubernetes. Use the container/service name once both run in the same Compose
project or namespace, and note that container-name DNS requires both containers
on the same user-defined network — Docker's default bridge does no name
resolution at all.
Rollback
Rebind the stock flow. Seconds — which is the whole reason you duplicated instead
of editing:
Authentication → Flows → browser → ⋮ → Bind flow → Browser flow.
Locked out of the console too:
UPDATE realm SET browser_flow = (
SELECT id FROM authentication_flow f
WHERE f.realm_id = realm.id AND f.alias = 'browser'
) WHERE name = '<realm>';
Then restart Keycloak to clear the cached flow.
9. Lessons worth keeping
- Theme or SPI — decide first. Wording, layout, and branding are a theme. Decisions are an SPI. Reaching for Java when a message key would do is how Keycloak customizations become unmaintainable.
-
Extend the built-in authenticator; don't insert a step before it. Inheriting
UsernamePasswordFormgets you password-before-check ordering, brute-force protection, and email-or-username lookup for free — and, more importantly, it keeps you from building a username-enumeration oracle. - Fail closed, always. Non-200, timeout, bad JSON, missing field: stay disabled. Your internal service being down must never be a way in.
-
Pin
getId()forever. It is a foreign key in every flow that uses it. -
One
META-INF/servicesfile per interface. A single misplaced line takes down unrelated providers viaServiceConfigurationError. - Know which base image family you are on. Entrypoint conventions, env var names, and the providers path all differ. Read the scripts out of the image instead of trusting a tutorial written for the other family.
-
Never trust
plugins/without checking the startup log. The stale-jar no-op wastes more time than any actual bug in this list. - Test what you can without a container. FreeMarker renders in a JUnit test in about a second. Assert that your override wins, not just that your string is present somewhere.
-
Script the flow wiring. Admin console clicks do not survive a rebuilt
environment; an idempotent script with
--verifyand--rollbackdoes.
Top comments (0)