<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ngoc Quang, Nguyen</title>
    <description>The latest articles on DEV Community by Ngoc Quang, Nguyen (@gnauqthebeast).</description>
    <link>https://dev.to/gnauqthebeast</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1931496%2F275d6c97-9ea3-430a-a0b6-8e43379b3af1.jpeg</url>
      <title>DEV Community: Ngoc Quang, Nguyen</title>
      <link>https://dev.to/gnauqthebeast</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gnauqthebeast"/>
    <language>en</language>
    <item>
      <title>Customizing Keycloak: Themes, Login Flows, and Disabled-User Handling</title>
      <dc:creator>Ngoc Quang, Nguyen</dc:creator>
      <pubDate>Sun, 27 Sep 2026 02:58:36 +0000</pubDate>
      <link>https://dev.to/gnauqthebeast/customizing-keycloak-themes-login-flows-and-disabled-user-handling-5chf</link>
      <guid>https://dev.to/gnauqthebeast/customizing-keycloak-themes-login-flows-and-disabled-user-handling-5chf</guid>
      <description>&lt;p&gt;A working guide to building your own Keycloak image — custom login UI, a custom&lt;br&gt;
authenticator that consults an internal service, and an event listener that pushes&lt;br&gt;
events out — packaged as one reproducible Docker image.&lt;/p&gt;

&lt;p&gt;Everything here is from a Keycloak &lt;strong&gt;24.x&lt;/strong&gt; deployment on &lt;strong&gt;Java 17&lt;/strong&gt;, built with&lt;br&gt;
Docker and Make. The general shape applies to 22–26; the exact class names and&lt;br&gt;
container paths are version-specific and called out where they matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get at the end&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Customization&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Ships as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Branded login / email / account pages&lt;/td&gt;
&lt;td&gt;FreeMarker theme + Tailwind&lt;/td&gt;
&lt;td&gt;&lt;code&gt;theme.jar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reworded or localized UI strings&lt;/td&gt;
&lt;td&gt;Theme message bundle&lt;/td&gt;
&lt;td&gt;same jar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra logic in the login flow&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Authenticator&lt;/code&gt; SPI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;provider.jar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disabled accounts revalidated by an internal service&lt;/td&gt;
&lt;td&gt;Custom &lt;code&gt;UsernamePasswordForm&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;same jar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth events pushed to your backend&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EventListenerProvider&lt;/code&gt; SPI&lt;/td&gt;
&lt;td&gt;same jar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two mechanisms are worth separating in your head up front: &lt;strong&gt;a theme changes&lt;br&gt;
what the user sees; an SPI changes what Keycloak does.&lt;/strong&gt; Many "we need a custom&lt;br&gt;
Keycloak" requests turn out to be theme-only, which is far cheaper. Reach for an&lt;br&gt;
SPI when you need a decision Keycloak cannot express in its own configuration.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. Initialize the project
&lt;/h2&gt;

&lt;p&gt;One repository, one image. Sources for each customization live beside the&lt;br&gt;
Dockerfile that bakes them in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;plugins/&lt;/code&gt; holds &lt;strong&gt;build outputs that are committed to git&lt;/strong&gt;. That is deliberate,&lt;br&gt;
and it is the one convention worth explaining to reviewers: the Docker build stays&lt;br&gt;
a single &lt;code&gt;COPY&lt;/code&gt; with no toolchain in it, so anyone can rebuild the image without&lt;br&gt;
Node or Maven installed. The cost is that a stale JAR is invisible — see the&lt;br&gt;
failure mode below.&lt;/p&gt;
&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;p&gt;Only Docker and Make on the host. Both toolchains run in containers, so nobody&lt;br&gt;
needs a matching JDK or Node version locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;        &lt;span class="c"&gt;# Buildx required for `make build`&lt;/span&gt;
make &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scaffold it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; keycloak/&lt;span class="o"&gt;{&lt;/span&gt;plugins,spi/src/main/&lt;span class="o"&gt;{&lt;/span&gt;java,resources/META-INF/services&lt;span class="o"&gt;}&lt;/span&gt;,themes,docs&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;keycloak &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git init
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'auth.tar.gz\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .dockerignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the theme, start from an existing open-source Keycloak theme rather than from&lt;br&gt;
nothing — the FreeMarker templates have a lot of implicit contract with the server,&lt;br&gt;
and a fork gives you every page already wired. Keywind (Tailwind-based) is a good&lt;br&gt;
starting point; so is a copy of Keycloak's own &lt;code&gt;base&lt;/code&gt; theme.&lt;/p&gt;


&lt;h2&gt;
  
  
  2. The Dockerfile
&lt;/h2&gt;

&lt;p&gt;Use a &lt;strong&gt;public&lt;/strong&gt; base image. Two families exist and they are &lt;strong&gt;not&lt;br&gt;
interchangeable at runtime&lt;/strong&gt; — this is the single biggest source of wasted hours&lt;br&gt;
in this whole exercise.&lt;/p&gt;
&lt;h3&gt;
  
  
  Option A — official image (recommended)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ---- stage 1: augment the server with our providers ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;quay.io/keycloak/keycloak:24.0.4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; plugins/*.jar /opt/keycloak/providers/&lt;/span&gt;

&lt;span class="c"&gt;# Bake providers + theme into an optimized server image.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;/opt/keycloak/bin/kc.sh build

&lt;span class="c"&gt;# ---- stage 2: runtime ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; quay.io/keycloak/keycloak:24.0.4&lt;/span&gt;

&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; GIT_COMMIT=unknown&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; GIT_BRANCH=unknown&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; BUILD_DATE=unknown&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; VERSION=1.0.0&lt;/span&gt;

&lt;span class="k"&gt;LABEL&lt;/span&gt;&lt;span class="s"&gt; org.opencontainers.image.created="${BUILD_DATE}" \&lt;/span&gt;
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${GIT_COMMIT}" \
      git.branch="${GIT_BRANCH}"

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /opt/keycloak/ /opt/keycloak/&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/opt/keycloak/bin/kc.sh"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;kc.sh build&lt;/code&gt; is the step people miss. Keycloak augments itself at build time;&lt;br&gt;
dropping a JAR into a running container and restarting does &lt;strong&gt;not&lt;/strong&gt; reliably&lt;br&gt;
register a new provider in an optimized image. Running &lt;code&gt;build&lt;/code&gt; in a stage and&lt;br&gt;
copying the result keeps startup fast and the provider registration durable.&lt;/p&gt;
&lt;h3&gt;
  
  
  Option B — Bitnami-convention image
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; docker.io/bitnami/keycloak:24.0.4&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; plugins /opt/bitnami/keycloak/providers&lt;/span&gt;
&lt;span class="c"&gt;# No ENTRYPOINT/CMD override — see the warning below.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Simpler, because Bitnami's &lt;code&gt;setup.sh&lt;/code&gt; runs &lt;code&gt;kc.sh build&lt;/code&gt; for you on first start.&lt;br&gt;
Two things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify the tag is still published before you depend on it. Bitnami has been
relocating older Docker Hub tags; if &lt;code&gt;bitnami/keycloak:24.0.4&lt;/code&gt; 404s, check the
&lt;code&gt;bitnamilegacy&lt;/code&gt; namespace or pin a tag you have mirrored yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never set &lt;code&gt;command:&lt;/code&gt; on this image.&lt;/strong&gt; Its entrypoint ends in a bare
&lt;code&gt;exec "$@"&lt;/code&gt;, so a &lt;code&gt;command: start-dev&lt;/code&gt; is looked up as a &lt;em&gt;binary&lt;/em&gt; and the
container dies with &lt;code&gt;exec: start-dev: not found&lt;/code&gt;. Worse, its setup script only
runs when the command contains &lt;code&gt;run.sh&lt;/code&gt; — so overriding the command also skips
database configuration, admin-user creation, and the build step. Dev vs.
production is an environment variable (&lt;code&gt;KEYCLOAK_PRODUCTION&lt;/code&gt;), not an argument.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Environment variables are not portable between the two
&lt;/h3&gt;

&lt;p&gt;If you switch families, every database variable changes name. Bitnami's startup&lt;br&gt;
script waits for the database in &lt;strong&gt;Bash, before the JVM launches&lt;/strong&gt;, reading only&lt;br&gt;
its own variable names — so Keycloak's config precedence rules never get a chance&lt;br&gt;
to apply, and an unrecognized &lt;code&gt;KC_DB_URL&lt;/code&gt; is silently ignored while the built-in&lt;br&gt;
default host is used instead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Official image&lt;/th&gt;
&lt;th&gt;Bitnami image&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB vendor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KC_DB=postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KEYCLOAK_DATABASE_VENDOR=postgresql&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB host&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(part of &lt;code&gt;KC_DB_URL&lt;/code&gt;)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KEYCLOAK_DATABASE_HOST&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB name&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(part of &lt;code&gt;KC_DB_URL&lt;/code&gt;)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KEYCLOAK_DATABASE_NAME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB user / password&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KC_DB_USERNAME&lt;/code&gt; / &lt;code&gt;KC_DB_PASSWORD&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KEYCLOAK_DATABASE_USER&lt;/code&gt; / &lt;code&gt;KEYCLOAK_DATABASE_PASSWORD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin bootstrap&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KEYCLOAK_ADMIN&lt;/code&gt; / &lt;code&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev vs prod&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;start-dev&lt;/code&gt; vs &lt;code&gt;start --optimized&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;`KEYCLOAK_PRODUCTION=false\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw {% raw %}&lt;code&gt;kc.sh&lt;/code&gt; flags&lt;/td&gt;
&lt;td&gt;appended to the command&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KEYCLOAK_EXTRA_ARGS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Providers path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/opt/keycloak/providers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/opt/bitnami/keycloak/providers&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To find the full set for any Bitnami tag, read it out of the image instead of&lt;br&gt;
guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &amp;lt;image&amp;gt; /opt/bitnami/scripts/keycloak-env.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Watch for provider collisions
&lt;/h3&gt;

&lt;p&gt;Base images often already ship popular community providers. Two versions of the&lt;br&gt;
same provider in &lt;code&gt;providers/&lt;/code&gt; produces a split-package warning at startup and&lt;br&gt;
&lt;strong&gt;which one wins is not under your control&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &amp;lt;your-image&amp;gt; /opt/keycloak/providers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a JAR you are adding is already there, drop yours and use the bundled one — or&lt;br&gt;
pin deliberately, but knowingly.&lt;/p&gt;


&lt;h2&gt;
  
  
  3. The build pipeline
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Makefile&lt;/code&gt; gives each artifact its own target, so a theme-only change does not&lt;br&gt;
rebuild Java and vice versa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;IMAGE_NAME&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; myorg/keycloak
&lt;span class="nv"&gt;IMAGE_TAG&lt;/span&gt;  &lt;span class="o"&gt;?=&lt;/span&gt; 1.0.0
&lt;span class="nv"&gt;FULL_IMAGE&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;IMAGE_NAME&lt;span class="p"&gt;)&lt;/span&gt;:&lt;span class="p"&gt;$(&lt;/span&gt;IMAGE_TAG&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;PLATFORM&lt;/span&gt;   &lt;span class="o"&gt;?=&lt;/span&gt; linux/amd64
&lt;span class="nv"&gt;SKIP_PUSH&lt;/span&gt;  &lt;span class="o"&gt;?=&lt;/span&gt; 1

&lt;span class="nv"&gt;SPI_JAR&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; my-keycloak-spi-1.0.0.jar
&lt;span class="nv"&gt;THEME_JAR&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; mytheme.jar
&lt;span class="nv"&gt;PLUGINS_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; plugins

&lt;span class="nv"&gt;GIT_COMMIT&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;shell git rev-parse &lt;span class="nt"&gt;--short&lt;/span&gt; HEAD 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;unknown&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;GIT_BRANCH&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;shell git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;unknown&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BUILD_DATE&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;shell &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +&lt;span class="s2"&gt;"%Y-%m-%dT%H:%M:%SZ"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Maven in a container: no local JDK needed, ~/.m2 cached across runs.
&lt;/span&gt;&lt;span class="nl"&gt;build-spi&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;CURDIR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/spi"&lt;/span&gt;:/app &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/.m2"&lt;/span&gt;:/root/.m2 &lt;span class="nt"&gt;-w&lt;/span&gt; /app &lt;span class="se"&gt;\&lt;/span&gt;
        maven:3.8-openjdk-17 mvn clean package &lt;span class="nt"&gt;-DskipTests&lt;/span&gt;
    &lt;span class="nb"&gt;cp &lt;/span&gt;spi/target/&lt;span class="p"&gt;$(&lt;/span&gt;SPI_JAR&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;PLUGINS_DIR&lt;span class="p"&gt;)&lt;/span&gt;/&lt;span class="p"&gt;$(&lt;/span&gt;SPI_JAR&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Node in a container, exporting only the jar via a scratch stage.
&lt;/span&gt;&lt;span class="nl"&gt;build-theme&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nv"&gt;DOCKER_BUILDKIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 docker build &lt;span class="nt"&gt;-f&lt;/span&gt; themes/mytheme/Dockerfile.build &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;local&lt;/span&gt;,dest&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;CURDIR&lt;span class="p"&gt;)&lt;/span&gt;/themes/mytheme/out/ &lt;span class="se"&gt;\&lt;/span&gt;
        themes/mytheme/
    &lt;span class="nb"&gt;cp &lt;/span&gt;themes/mytheme/out/&lt;span class="p"&gt;$(&lt;/span&gt;THEME_JAR&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;PLUGINS_DIR&lt;span class="p"&gt;)&lt;/span&gt;/&lt;span class="p"&gt;$(&lt;/span&gt;THEME_JAR&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nl"&gt;build&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker buildx build &lt;span class="nt"&gt;--platform&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;PLATFORM&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;GIT_COMMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;GIT_COMMIT&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;GIT_BRANCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;GIT_BRANCH&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;BUILD_DATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;BUILD_DATE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;IMAGE_TAG&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--load&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;FULL_IMAGE&lt;span class="p"&gt;)&lt;/span&gt; .
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;SKIP_PUSH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;docker push &lt;span class="p"&gt;$(&lt;/span&gt;FULL_IMAGE&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SKIP_PUSH=1&lt;/code&gt; by default: pushing should be something you ask for, not something&lt;br&gt;
that happens because you typed &lt;code&gt;make&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The theme builder uses &lt;code&gt;FROM scratch&lt;/code&gt; as its final stage so BuildKit's&lt;br&gt;
&lt;code&gt;--output type=local&lt;/code&gt; writes just the jar to the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# themes/mytheme/Dockerfile.build&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pnpm@8
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /assets&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /assets&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm build:jar

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; scratch&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /assets/out .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The stale-JAR trap
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;make build-spi&lt;/code&gt; must &lt;strong&gt;succeed&lt;/strong&gt; before &lt;code&gt;make build&lt;/code&gt;. If Maven fails, &lt;code&gt;plugins/&lt;/code&gt;&lt;br&gt;
still holds the &lt;em&gt;previous&lt;/em&gt; JAR and the image builds cleanly — shipping without&lt;br&gt;
your change. It presents as "my code isn't running", and you will look for the bug&lt;br&gt;
in your code. Chain them so a failure stops the line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make build-spi &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make build-theme &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then confirm at startup (section 7) rather than assuming.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Customize the UI with a theme
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Anatomy of a theme jar
&lt;/h3&gt;

&lt;p&gt;A Keycloak theme is a jar with exactly two things in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"themes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mytheme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"login"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"welcome"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Packaging is just a zip — no Maven needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/build.ts  (run with vite-node / tsx)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;archiver&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;archiver&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createWriteStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mkdirSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;archiver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createWriteStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mytheme.jar`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;META-INF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;META-INF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finalize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Inherit, don't rewrite
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;theme.properties&lt;/code&gt; is where you choose how much you own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# theme/mytheme/login/theme.properties
&lt;/span&gt;&lt;span class="py"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;base                 # inherit every .ftl and message you don't override&lt;/span&gt;
&lt;span class="py"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;dist/index.css       # injected into &amp;lt;head&amp;gt; by the base template&lt;/span&gt;
&lt;span class="py"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;dist/index.js&lt;/span&gt;
&lt;span class="py"&gt;MY_PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${env.MY_PRODUCT_NAME}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;parent=base&lt;/code&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parent=keycloak&lt;/code&gt; / &lt;code&gt;parent=keycloak.v2&lt;/code&gt; — inherit Keycloak's own look and patch
it. Right for the &lt;code&gt;account&lt;/code&gt; and &lt;code&gt;admin&lt;/code&gt; themes, where rewriting is rarely worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;${env.VAR}&lt;/code&gt; reads a container environment variable at render time. That is how you&lt;br&gt;
get one image serving several brands: same jar, different &lt;code&gt;MY_PRODUCT_NAME&lt;/code&gt; per&lt;br&gt;
environment. Reference it in a template as &lt;code&gt;${properties.MY_PRODUCT_NAME}&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Styling with Tailwind
&lt;/h3&gt;

&lt;p&gt;Vite compiles into the theme's &lt;code&gt;resources/dist&lt;/code&gt;, which Keycloak serves as static&lt;br&gt;
assets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/index.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme/mytheme/login/resources/dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;entryFileNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name].js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;assetFileNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name][extname]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailwind must be told to scan &lt;code&gt;.ftl&lt;/code&gt; files or it will purge every class you use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tailwind.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./theme/**/*.ftl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* your palette */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tailwindcss/forms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Build reusable macros
&lt;/h3&gt;

&lt;p&gt;Rather than repeating markup across twenty templates, factor components out. An&lt;br&gt;
input macro that also handles the show/hide password toggle (Alpine.js here):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight fluent"&gt;&lt;code&gt;&lt;span class="err"&gt;&amp;lt;#--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;components/atoms/input.ftl --&amp;gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;#&lt;/span&gt;&lt;span class="no"&gt;macro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kw name="" label="" type="text" required=true invalid=false message="" rest...&amp;gt;&lt;span class="w"&gt;
  &lt;/span&gt;&amp;lt;div&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;label class="block text-sm font-semibold text-gray-700 mb-1" for="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;"&amp;gt;&lt;span class="w"&gt;
      &lt;/span&gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&amp;lt;#if required&amp;gt;&amp;lt;span class="text-red-600"&amp;gt;&lt;span class="err"&gt;*&lt;/span&gt;&amp;lt;/span&amp;gt;&amp;lt;/#if&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;/label&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;#if type == "password"&amp;gt;&lt;span class="w"&gt;
      &lt;/span&gt;&amp;lt;div class="relative" x-data="&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;show&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;"&amp;gt;&lt;span class="w"&gt;
        &lt;/span&gt;&amp;lt;input id="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;" name="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;" :type="show ? 'text' : 'password'"&lt;span class="w"&gt;
               &lt;/span&gt;aria-invalid="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;invalid&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="no"&gt;c&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;" class="..."&lt;span class="w"&gt;
               &lt;/span&gt;&amp;lt;#list rest as k, v&amp;gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;"&amp;lt;/#list&amp;gt;&amp;gt;&lt;span class="w"&gt;
        &lt;/span&gt;&amp;lt;button type="button" @click="show = !show" aria-controls="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;"&lt;span class="w"&gt;
                &lt;/span&gt;:aria-expanded="show"&amp;gt;…&amp;lt;/button&amp;gt;&lt;span class="w"&gt;
      &lt;/span&gt;&amp;lt;/div&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;#else&amp;gt;&lt;span class="w"&gt;
      &lt;/span&gt;&amp;lt;input id="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;" name="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;" type="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;" aria-invalid="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;invalid&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="no"&gt;c&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;"&lt;span class="w"&gt;
             &lt;/span&gt;class="..." &amp;lt;#list rest as k, v&amp;gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;="$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;"&amp;lt;/#list&amp;gt;&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;/#if&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;#if invalid &amp;amp;&amp;amp; message?has_content&amp;gt;&lt;span class="w"&gt;
      &lt;/span&gt;&amp;lt;div class="mt-2 text-red-600 text-sm"&amp;gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;message&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="no"&gt;no_esc&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&amp;lt;/div&amp;gt;&lt;span class="w"&gt;
    &lt;/span&gt;&amp;lt;/#if&amp;gt;&lt;span class="w"&gt;
  &lt;/span&gt;&amp;lt;/div&amp;gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;/#&lt;/span&gt;&lt;span class="no"&gt;macro&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then every page is short and consistent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight fluent"&gt;&lt;code&gt;&lt;span class="err"&gt;&amp;lt;@&lt;/span&gt;&lt;span class="no"&gt;input&lt;/span&gt;&lt;span class="na"&gt;.kw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;name="username" label=usernameLabel autofocus=true&lt;span class="w"&gt;
  &lt;/span&gt;invalid=messagesPerField.existsError("username")&lt;span class="w"&gt;
  &lt;/span&gt;message=kcSanitize(messagesPerField.get("username")) /&amp;gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two FreeMarker rules that are security-relevant, not stylistic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;${...}&lt;/code&gt; escapes HTML by default — leave it that way.&lt;/li&gt;
&lt;li&gt;Any value you must render as HTML goes through &lt;code&gt;kcSanitize(...)?no_esc&lt;/code&gt;, never
&lt;code&gt;?no_esc&lt;/code&gt; alone. &lt;code&gt;kcSanitize&lt;/code&gt; is Keycloak's allow-list sanitizer; skipping it on
user- or realm-supplied text is an XSS hole on your login page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Worked example: rewording a message
&lt;/h3&gt;

&lt;p&gt;The smallest useful customization, and a good illustration of how the message&lt;br&gt;
bundle layers. Keycloak's &lt;code&gt;base&lt;/code&gt; theme ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;emailInstruction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Enter your username or email address and we will send you instructions on how to create a new password.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your realm only allows email login, that sentence is wrong. You do &lt;strong&gt;not&lt;/strong&gt; edit&lt;br&gt;
the template — &lt;code&gt;login-reset-password.ftl&lt;/code&gt; already renders the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight fluent"&gt;&lt;code&gt;&lt;span class="err"&gt;&amp;lt;#&lt;/span&gt;&lt;span class="no"&gt;elseif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;section="info"&amp;gt;&lt;span class="w"&gt;
  &lt;/span&gt;$&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"emailInstruction"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Override just the key in your theme's bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# theme/mytheme/login/messages/messages_en.properties
# Password reset messages
&lt;/span&gt;&lt;span class="py"&gt;emailInstruction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Enter email address and we will send you instructions on how to create a new password.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keycloak resolves &lt;code&gt;msg(...)&lt;/code&gt; through your theme first, then the parent chain, so&lt;br&gt;
one line changes the page and nothing else is touched. The same file is where&lt;br&gt;
every other string override lives — &lt;code&gt;loginTitle&lt;/code&gt;, field labels, validation text.&lt;br&gt;
Add &lt;code&gt;messages_&amp;lt;locale&amp;gt;.properties&lt;/code&gt; siblings for other languages.&lt;/p&gt;

&lt;p&gt;Two things that bite here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A typo'd key fails silently.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End the file with a newline.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Test templates without starting Keycloak
&lt;/h3&gt;

&lt;p&gt;FreeMarker is renderable in a plain JUnit test. Keycloak publishes the theme&lt;br&gt;
support classes, so you can assert on real rendered HTML in about a second —&lt;br&gt;
instead of rebuilding an image and clicking through a browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldRenderEmailOnlyPasswordResetInstruction&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;Configuration&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createFreeMarkerConfiguration&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
  &lt;span class="nc"&gt;Template&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTemplate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"login-reset-password.ftl"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

  &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pageText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;formatHtml&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;renderTemplate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// jsoup&lt;/span&gt;

  &lt;span class="n"&gt;assertTrue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageText&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;"Enter email address and we will send you instructions on how to create a new password."&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
  &lt;span class="n"&gt;assertFalse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageText&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"username or email"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;assertFalse&lt;/code&gt; is the load-bearing half: it proves your override actually won,&lt;br&gt;
rather than the parent's string leaking through a typo'd key.&lt;/p&gt;

&lt;p&gt;Wire the configuration so the loader reads the &lt;strong&gt;base&lt;/strong&gt; bundle &lt;em&gt;and then&lt;/em&gt; your&lt;br&gt;
theme's, mirroring runtime precedence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Properties&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Properties&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// 1. base messages from the keycloak-themes jar on the test classpath&lt;/span&gt;
&lt;span class="c1"&gt;// 2. then your theme's, which overwrite matching keys&lt;/span&gt;
&lt;span class="nc"&gt;Path&lt;/span&gt; &lt;span class="n"&gt;themeMessages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;THEME_PATH&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messages"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"messages_en.properties"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InputStream&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newInputStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;themeMessages&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test dependencies: &lt;code&gt;freemarker&lt;/code&gt;, &lt;code&gt;jsoup&lt;/code&gt;, &lt;code&gt;junit-jupiter&lt;/code&gt;, plus Keycloak's&lt;br&gt;
&lt;code&gt;keycloak-themes&lt;/code&gt;, &lt;code&gt;keycloak-services&lt;/code&gt;, and &lt;code&gt;keycloak-server-spi-private&lt;/code&gt; for&lt;br&gt;
&lt;code&gt;kcSanitize&lt;/code&gt; and the &lt;code&gt;MessagesPerFieldBean&lt;/code&gt; / &lt;code&gt;MessageFormatterMethod&lt;/code&gt; beans the&lt;br&gt;
templates expect in scope.&lt;/p&gt;
&lt;h3&gt;
  
  
  Activate it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Realm settings → Themes&lt;/strong&gt;, pick your theme per type (Login, Account, Email,&lt;br&gt;
Admin), Save. For a fresh environment, set it in the realm import JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"realm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myrealm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"loginTheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mytheme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"emailTheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mytheme"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During development, skip the rebuild loop entirely — mount the theme directory and&lt;br&gt;
turn caching off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./themes/mytheme/theme:/opt/keycloak/themes&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;KC_SPI_THEME_STATIC_MAX_AGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-1"&lt;/span&gt;
  &lt;span class="na"&gt;KC_SPI_THEME_CACHE_THEMES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
  &lt;span class="na"&gt;KC_SPI_THEME_CACHE_TEMPLATES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a &lt;code&gt;.ftl&lt;/code&gt; edit is visible on refresh. Build the jar only when you are done.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Customize the login flow with an SPI
&lt;/h2&gt;

&lt;p&gt;Now the server-behavior half. The example: &lt;strong&gt;an account disabled in Keycloak&lt;br&gt;
should be revalidated against an internal service before we refuse the login&lt;/strong&gt; —&lt;br&gt;
and, if the service says the account is fine, optionally re-enabled so the user&lt;br&gt;
gets in.&lt;/p&gt;
&lt;h3&gt;
  
  
  Project setup
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;properties&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;maven.compiler.source&amp;gt;&lt;/span&gt;17&lt;span class="nt"&gt;&amp;lt;/maven.compiler.source&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;maven.compiler.target&amp;gt;&lt;/span&gt;17&lt;span class="nt"&gt;&amp;lt;/maven.compiler.target&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;keycloak.version&amp;gt;&lt;/span&gt;24.0.0&lt;span class="nt"&gt;&amp;lt;/keycloak.version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/properties&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dependencies&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.keycloak&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;keycloak-server-spi&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;${keycloak.version}&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&amp;lt;scope&amp;gt;&lt;/span&gt;provided&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.keycloak&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;keycloak-server-spi-private&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;${keycloak.version}&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&amp;lt;scope&amp;gt;&lt;/span&gt;provided&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.keycloak&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;keycloak-services&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;${keycloak.version}&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&amp;lt;scope&amp;gt;&lt;/span&gt;provided&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.fasterxml.jackson.core&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;jackson-databind&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.16.1&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&amp;lt;scope&amp;gt;&lt;/span&gt;provided&lt;span class="nt"&gt;&amp;lt;/scope&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependencies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Every Keycloak dependency is &lt;code&gt;provided&lt;/code&gt;.&lt;/strong&gt; So is Jackson, and so is&lt;br&gt;
&lt;code&gt;jboss-logging&lt;/code&gt; — the server already has them. Bundling your own copy either&lt;br&gt;
inflates the jar harmlessly or breaks classloading in ways that are painful to&lt;br&gt;
diagnose. Your jar should contain your classes and nothing else.&lt;/p&gt;

&lt;p&gt;Match &lt;code&gt;keycloak.version&lt;/code&gt; to the server you deploy against. Internal SPI classes&lt;br&gt;
are explicitly allowed to change between minor versions.&lt;/p&gt;
&lt;h3&gt;
  
  
  Extend the built-in form, don't replace it
&lt;/h3&gt;

&lt;p&gt;The instinct is to add a new step before the login form. &lt;strong&gt;Don't.&lt;/strong&gt; A standalone&lt;br&gt;
step that looks up the user and calls out would leak "this account exists, and&lt;br&gt;
here is why it is blocked" to anyone who can type a username into a public form.&lt;/p&gt;

&lt;p&gt;Instead, subclass &lt;code&gt;UsernamePasswordForm&lt;/code&gt; and override the one method where&lt;br&gt;
Keycloak decides an account is disabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DisabledUserAuthenticator&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;UsernamePasswordForm&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

  &lt;span class="nd"&gt;@Override&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;enabledUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationFlowContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserModel&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Brute-force protection stays ahead of any outbound call.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isDisabledByBruteForce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// normal path — the internal service is never called&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;askInternalService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;reenable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isReenableEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;reenable&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEnabled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEvent&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REASON&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"revalidated"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;infof&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user=%s ALLOWED, account re-enabled"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUsername&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEvent&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEvent&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_DISABLED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forceChallenge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Messages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACCOUNT_DISABLED&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties come free from that choice, and all three are load-bearing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The password is verified first.&lt;/strong&gt; The base class runs &lt;code&gt;validatePassword(...)&lt;/code&gt;
&lt;em&gt;before&lt;/em&gt; &lt;code&gt;enabledUser(...)&lt;/code&gt;, so the internal service is only ever called for a
caller who already proved the password. No username-enumeration oracle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brute-force protection stays in front&lt;/strong&gt;, exactly as in the base class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username-or-email resolution is inherited&lt;/strong&gt;, so realms with "Login with
email" enabled keep working without you reimplementing lookup.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Re-enabling is the only way to admit a disabled user.&lt;/strong&gt; Keycloak's&lt;br&gt;
&lt;code&gt;AuthenticationProcessor.validateUser()&lt;/code&gt; re-checks &lt;code&gt;user.isEnabled()&lt;/code&gt; at several&lt;br&gt;
points &lt;em&gt;after&lt;/em&gt; the flow completes. "Allow this one login but leave the account&lt;br&gt;
disabled" is not reachable from an authenticator — it requires flipping the flag.&lt;br&gt;
Design around that, or you will spend a day proving it to yourself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Call the internal service, and fail closed
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="nf"&gt;askInternalService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationFlowContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserModel&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buildUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CONFIG_URL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_URL&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invalid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;HttpRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;URI&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Accept"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;GET&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CONFIG_AUTH_HEADER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;headerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CONFIG_AUTH_HEADER_NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_AUTH_HEADER_NAME&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headerName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BodyHandlers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;infof&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET %s -&amp;gt; status=%d took=%dms body=%s"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;truncate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invalid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

  &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InterruptedException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentThread&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;interrupt&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invalid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fail closed: any transport or parse failure leaves the account disabled.&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;errorf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"check FAILED for user %s at %s (%s)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUsername&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClass&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getSimpleName&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invalid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract, kept deliberately small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET {url}?userId=&amp;lt;keycloak-user-id&amp;gt;   →   200 {"valid": true|false, "reason": "..."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules worth encoding rather than documenting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A missing &lt;code&gt;valid&lt;/code&gt; field counts as invalid.&lt;/strong&gt; Otherwise a proxy error page
returned with status 200 becomes an authentication bypass.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"valid"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isMissingNode&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"response has no 'valid' field, so it counts as invalid"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"valid"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asBoolean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-200, timeout, unparseable body → stay disabled.&lt;/strong&gt; Availability of your
internal service must never become a way in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Truncate &lt;code&gt;reason&lt;/code&gt; before rendering it&lt;/strong&gt; (200 chars here). It is remote text
headed for a public page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;reason&lt;/code&gt; category-level.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timeout blocks the login request.&lt;/strong&gt; Keep it at a few seconds. There is no
"slow but eventually correct" here — there is a user watching a spinner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The factory: registration and configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DisabledUserAuthenticatorFactory&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AuthenticatorFactory&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;PROVIDER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"internal-disabled-user-checker"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationExecutionModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Requirement&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="no"&gt;REQUIREMENT_CHOICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="nc"&gt;AuthenticationExecutionModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Requirement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REQUIRED&lt;/span&gt;
  &lt;span class="o"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="nc"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="nc"&gt;DisabledUserAuthenticator&lt;/span&gt; &lt;span class="n"&gt;authenticator&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

  &lt;span class="nd"&gt;@Override&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Scope&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// One client per provider lifecycle, built once configuration is available.&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;httpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;connectTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"connectTimeoutSeconds"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authenticator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DisabledUserAuthenticator&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Authenticator&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeycloakSession&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;authenticator&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;PROVIDER_ID&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getDisplayType&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Internal Username Password Form"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isConfigurable&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nd"&gt;@Override&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationExecutionModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Requirement&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;getRequirementChoices&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;REQUIREMENT_CHOICES&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@Override&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getConfigProperties&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"check.url"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLabel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Check URL"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING_TYPE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDefaultValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DEFAULT_URL&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setHelpText&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Queried when a disabled user submits correct credentials. "&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"'userId' is appended as a query parameter."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"auth.header"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLabel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Auth header value"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PASSWORD&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// masked in the console&lt;/span&gt;

    &lt;span class="cm"&gt;/* … timeout (STRING_TYPE), reenable (BOOLEAN_TYPE), header name … */&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asList&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reenable&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headerName&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Points that pay for themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the &lt;code&gt;HttpClient&lt;/code&gt; in &lt;code&gt;init()&lt;/code&gt;&lt;/strong&gt;, not per request and not in a static
initializer. One client per provider lifecycle; on Java 17 &lt;code&gt;HttpClient&lt;/code&gt; is not
&lt;code&gt;Closeable&lt;/code&gt;, so &lt;code&gt;close()&lt;/code&gt; has nothing to release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ProviderConfigProperty.PASSWORD&lt;/code&gt;&lt;/strong&gt; masks the value in the admin console.
Credentials belong in flow config, not in the code or the image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restricting &lt;code&gt;getRequirementChoices()&lt;/code&gt; to &lt;code&gt;REQUIRED&lt;/code&gt;&lt;/strong&gt; removes a whole class of
misconfiguration. An &lt;code&gt;ALTERNATIVE&lt;/code&gt; login form is almost never what anyone means.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default the dangerous switch to off.&lt;/strong&gt; &lt;code&gt;reenable&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;:
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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getId()&lt;/code&gt; is permanent.&lt;/strong&gt; It is the string stored in every flow row that
references the provider. Renaming &lt;code&gt;getDisplayType()&lt;/code&gt; is cosmetic and safe;
changing &lt;code&gt;getId()&lt;/code&gt; breaks every flow already using it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Registration: one file per SPI interface
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/main/resources/META-INF/services/
├── org.keycloak.authentication.AuthenticatorFactory      → com.example.keycloak.DisabledUserAuthenticatorFactory
└── org.keycloak.events.EventListenerProviderFactory      → com.example.keycloak.EventListenerProviderFactory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file name &lt;strong&gt;is&lt;/strong&gt; the interface name; each line is one implementation class.&lt;/p&gt;

&lt;p&gt;This is worth being pedantic about: listing an &lt;code&gt;AuthenticatorFactory&lt;/code&gt; inside the&lt;br&gt;
&lt;code&gt;EventListenerProviderFactory&lt;/code&gt; file makes &lt;code&gt;ServiceLoader&lt;/code&gt; throw&lt;br&gt;
&lt;code&gt;ServiceConfigurationError: not a subtype&lt;/code&gt;, which &lt;strong&gt;aborts the whole enumeration&lt;/strong&gt;&lt;br&gt;
— taking your previously working event listener down with it. One wrong line in&lt;br&gt;
one file silently disables an unrelated, correct provider.&lt;/p&gt;


&lt;h2&gt;
  
  
  6. Wire the authenticator into the browser flow
&lt;/h2&gt;

&lt;p&gt;Installing the jar makes the authenticator &lt;em&gt;available&lt;/em&gt;. It does nothing until a&lt;br&gt;
flow uses it. &lt;strong&gt;Duplicate the built-in flow; never edit it&lt;/strong&gt; — the copy is your&lt;br&gt;
rollback.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication → Flows → &lt;code&gt;browser&lt;/code&gt; → ⋮ → Duplicate&lt;/strong&gt;, name it &lt;code&gt;browser-custom&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Open it. In the &lt;strong&gt;&lt;code&gt;forms&lt;/code&gt;&lt;/strong&gt; subflow, delete &lt;strong&gt;Username Password Form&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On the &lt;code&gt;forms&lt;/code&gt; row: &lt;strong&gt;+ → Add step&lt;/strong&gt; → pick your display name → &lt;strong&gt;Add&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set it &lt;strong&gt;Required&lt;/strong&gt;, drag it &lt;strong&gt;above&lt;/strong&gt; the conditional-OTP subflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;⚙ gear&lt;/strong&gt; → fill in URL, timeout, header name, credential → &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;⋮ → Bind flow → Browser flow → Save.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Add &lt;strong&gt;step&lt;/strong&gt;, not &lt;strong&gt;sub-flow&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The single easiest mistake here, and it fails in a confusing way. "Add sub-flow"&lt;br&gt;
creates an &lt;strong&gt;empty container&lt;/strong&gt; named after your authenticator; "Add step"&lt;br&gt;
attaches the authenticator itself. An empty REQUIRED sub-flow makes the flow&lt;br&gt;
complete with no challenge and no authenticated user:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KC-SERVICES0013: Failed authentication: org.keycloak.authentication.AuthenticationFlowException
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Because you also deleted the real Username Password Form, &lt;strong&gt;nobody in that realm&lt;br&gt;
can log in&lt;/strong&gt; — not just disabled users. Verify before you test in a browser.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Script it instead
&lt;/h3&gt;

&lt;p&gt;Console clicking does not survive a rebuilt environment. The admin REST API does,&lt;br&gt;
and the script becomes your runbook. Make it &lt;strong&gt;idempotent&lt;/strong&gt; (reuse and update&lt;br&gt;
rather than duplicate) and give it &lt;code&gt;--verify&lt;/code&gt; and &lt;code&gt;--rollback&lt;/code&gt; modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/setup-auth.sh                &lt;span class="c"&gt;# create/duplicate flow, add step, configure, bind&lt;/span&gt;
./scripts/setup-auth.sh &lt;span class="nt"&gt;--verify&lt;/span&gt;       &lt;span class="c"&gt;# report state, change nothing&lt;/span&gt;
./scripts/setup-auth.sh &lt;span class="nt"&gt;--rollback&lt;/span&gt;     &lt;span class="c"&gt;# rebind the stock browser flow&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sketch of the core, with &lt;code&gt;curl&lt;/code&gt; + &lt;code&gt;jq&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=admin-cli"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"username=&lt;/span&gt;&lt;span class="nv"&gt;$ADMIN_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"password=&lt;/span&gt;&lt;span class="nv"&gt;$ADMIN_PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KC_URL&lt;/span&gt;&lt;span class="s2"&gt;/realms/&lt;/span&gt;&lt;span class="nv"&gt;$ADMIN_REALM&lt;/span&gt;&lt;span class="s2"&gt;/protocol/openid-connect/token"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .access_token&lt;span class="si"&gt;)&lt;/span&gt;

api&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 1. copy the stock browser flow&lt;/span&gt;
api &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KC_URL&lt;/span&gt;&lt;span class="s2"&gt;/admin/realms/&lt;/span&gt;&lt;span class="nv"&gt;$REALM&lt;/span&gt;&lt;span class="s2"&gt;/authentication/flows/browser/copy"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;newName&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$FLOW_NAME&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;

&lt;span class="c"&gt;# 2. add the execution to the 'forms' subflow, set REQUIRED, attach config,&lt;/span&gt;
&lt;span class="c"&gt;#    reorder above conditional OTP, then:&lt;/span&gt;
&lt;span class="c"&gt;# 3. bind it&lt;/span&gt;
api &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KC_URL&lt;/span&gt;&lt;span class="s2"&gt;/admin/realms/&lt;/span&gt;&lt;span class="nv"&gt;$REALM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;browserFlow&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$FLOW_NAME&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never default a real admin password inside the script. Read it from the&lt;br&gt;
environment and let a flag override.&lt;/p&gt;


&lt;h2&gt;
  
  
  7. Push events out with an event listener
&lt;/h2&gt;

&lt;p&gt;The other common SPI: react to what happens in Keycloak. An &lt;code&gt;EventListenerProvider&lt;/code&gt;&lt;br&gt;
sees every user and admin event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MyEventListenerProvider&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;EventListenerProvider&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

  &lt;span class="nd"&gt;@Override&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;EventType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;VERIFY_EMAIL&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// filter narrowly and early&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;ObjectNode&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createObjectNode&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"event_type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"timestamp"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofEpochMilli&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTime&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="c1"&gt;// Events carry ids, not profiles. Enrich from the session if you need fields.&lt;/span&gt;
    &lt;span class="nc"&gt;RealmModel&lt;/span&gt; &lt;span class="n"&gt;realm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;realms&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getRealm&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRealmId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;UserModel&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getUserById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUserId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="cm"&gt;/* … copy the attributes your consumer needs … */&lt;/span&gt;

    &lt;span class="n"&gt;sendWebhookAsync&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// never block the request thread&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@Override&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AdminEvent&lt;/span&gt; &lt;span class="n"&gt;adminEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;includeRepresentation&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Differences from the authenticator that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never block.&lt;/strong&gt; An authenticator's HTTP call is synchronous because the decision
depends on it. A listener's does not — use &lt;code&gt;sendAsync&lt;/code&gt; / &lt;code&gt;CompletableFuture&lt;/code&gt; and
let a failure be a log line, not a failed login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter on &lt;code&gt;EventType&lt;/code&gt; first.&lt;/strong&gt; A busy realm emits a lot of events, and an
unfiltered listener is a load generator pointed at your own backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events are thin.&lt;/strong&gt; They carry &lt;code&gt;userId&lt;/code&gt; and &lt;code&gt;realmId&lt;/code&gt;, so enrich from
&lt;code&gt;session.users()&lt;/code&gt; when the consumer needs email or attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config comes from the environment&lt;/strong&gt; (&lt;code&gt;System.getenv&lt;/code&gt;), not flow config —
listeners have no per-execution config UI. Keep a kill switch (&lt;code&gt;..._ENABLED&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enable it per realm: &lt;strong&gt;Realm settings → Events → Event listeners&lt;/strong&gt;, add your&lt;br&gt;
provider id. A provider that loads but is not listed there simply never runs.&lt;/p&gt;


&lt;h2&gt;
  
  
  8. Verify it actually loaded
&lt;/h2&gt;

&lt;p&gt;Do this before debugging anything else. It takes ten seconds and rules out the&lt;br&gt;
stale-jar trap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs &amp;lt;keycloak-container&amp;gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;KC-SERVICES0047
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KC-SERVICES0047: internal-disabled-user-checker (com.example.keycloak.DisabledUserAuthenticatorFactory)
  is implementing the internal SPI authenticator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;KC-SERVICES0047&lt;/code&gt; is a routine "internal SPI may change without notice" notice,&lt;br&gt;
&lt;strong&gt;not an error&lt;/strong&gt;. If the line is missing, your jar is stale or the build failed —&lt;br&gt;
fix that before touching flows.&lt;/p&gt;

&lt;p&gt;Is it a real step, or an empty sub-flow? The database answers faster than the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;db-container&amp;gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; keycloak &lt;span class="nt"&gt;-d&lt;/span&gt; keycloak &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT count(*) FROM authentication_execution
   WHERE authenticator='internal-disabled-user-checker';"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Must return &lt;strong&gt;1&lt;/strong&gt;. &lt;code&gt;0&lt;/code&gt; means you have the empty sub-flow.&lt;/p&gt;

&lt;p&gt;Is the flow bound?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;db-container&amp;gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; keycloak &lt;span class="nt"&gt;-d&lt;/span&gt; keycloak &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"SELECT r.name||' -&amp;gt; '||f.alias FROM realm r
   JOIN authentication_flow f ON f.id=r.browser_flow;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Can Keycloak reach the internal service &lt;em&gt;from inside the container&lt;/em&gt;?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;keycloak-container&amp;gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-api-key: &lt;/span&gt;&lt;span class="nv"&gt;$API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://internal-service:8080/api/v1/internal/users/check-disabled?userId=test-123"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch it run — grep the &lt;strong&gt;class name&lt;/strong&gt;, not your project name, or you will catch&lt;br&gt;
the unrelated event listener's lines too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs &lt;span class="nt"&gt;-f&lt;/span&gt; &amp;lt;keycloak-container&amp;gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;DisabledUserAuthenticator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A disabled user submitting the correct password produces two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;GET http://.../check-disabled?userId=4bb0336a-… -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200 &lt;span class="nv"&gt;took&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;66ms &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="o"&gt;={&lt;/span&gt;&lt;span class="s2"&gt;"valid"&lt;/span&gt;:false,&lt;span class="s2"&gt;"reason"&lt;/span&gt;:&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="go"&gt;user=alice REFUSED - Your account is inactive.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Nothing is logged for an enabled user or a wrong password.&lt;/strong&gt; By design — the&lt;br&gt;
check only runs for a disabled user who has already proven their password.&lt;/p&gt;
&lt;h3&gt;
  
  
  Troubleshooting
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No authenticator log lines at all&lt;/td&gt;
&lt;td&gt;Step never added, or added as a sub-flow. Run the count query.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;KC-SERVICES0047&lt;/code&gt; missing at startup&lt;/td&gt;
&lt;td&gt;Stale jar — the SPI build failed, or the image build was skipped.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AuthenticationFlowException&lt;/code&gt; and nobody can log in&lt;/td&gt;
&lt;td&gt;Empty sub-flow where the login form used to be.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ConnectException&lt;/code&gt; on the outbound call&lt;/td&gt;
&lt;td&gt;Wrong hostname from inside the container (see below).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status=401&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing or malformed credential header. Check for an accidental &lt;code&gt;Bearer&lt;/code&gt; prefix.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response has no &lt;code&gt;valid&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;The service returned a different shape — or a proxy error page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service says valid but login still refused&lt;/td&gt;
&lt;td&gt;The re-enable switch is off. The log says so explicitly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Theme change not visible&lt;/td&gt;
&lt;td&gt;Theme cache. Set the &lt;code&gt;KC_SPI_THEME_CACHE_*&lt;/code&gt; vars, or rebuild the jar.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overridden message still shows the old text&lt;/td&gt;
&lt;td&gt;Typo'd key (you got the parent's string) or a missing trailing newline.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Hostnames from inside a container.&lt;/strong&gt; &lt;code&gt;host.docker.internal&lt;/code&gt; reaches the host&lt;br&gt;
machine — use it when the internal service runs natively while Keycloak runs in&lt;br&gt;
Docker. It is a Docker-Desktop-only name: it does not exist on Linux servers or in&lt;br&gt;
Kubernetes. Use the container/service name once both run in the same Compose&lt;br&gt;
project or namespace, and note that container-name DNS requires &lt;strong&gt;both&lt;/strong&gt; containers&lt;br&gt;
on the &lt;strong&gt;same user-defined network&lt;/strong&gt; — Docker's default &lt;code&gt;bridge&lt;/code&gt; does no name&lt;br&gt;
resolution at all.&lt;/p&gt;
&lt;h3&gt;
  
  
  Rollback
&lt;/h3&gt;

&lt;p&gt;Rebind the stock flow. Seconds — which is the whole reason you duplicated instead&lt;br&gt;
of editing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication → Flows → &lt;code&gt;browser&lt;/code&gt; → ⋮ → Bind flow → Browser flow.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Locked out of the console too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;realm&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;browser_flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;authentication_flow&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;realm_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;alias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'browser'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;realm&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart Keycloak to clear the cached flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Lessons worth keeping
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Theme or SPI — decide first.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extend the built-in authenticator; don't insert a step before it.&lt;/strong&gt; Inheriting
&lt;code&gt;UsernamePasswordForm&lt;/code&gt; gets 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed, always.&lt;/strong&gt; Non-200, timeout, bad JSON, missing field: stay
disabled. Your internal service being down must never be a way in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin &lt;code&gt;getId()&lt;/code&gt; forever.&lt;/strong&gt; It is a foreign key in every flow that uses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One &lt;code&gt;META-INF/services&lt;/code&gt; file per interface.&lt;/strong&gt; A single misplaced line takes
down unrelated providers via &lt;code&gt;ServiceConfigurationError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which base image family you are on.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never trust &lt;code&gt;plugins/&lt;/code&gt; without checking the startup log.&lt;/strong&gt; The stale-jar
no-op wastes more time than any actual bug in this list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test what you can without a container.&lt;/strong&gt; FreeMarker renders in a JUnit test in
about a second. Assert that your override &lt;em&gt;wins&lt;/em&gt;, not just that your string is
present somewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Script the flow wiring.&lt;/strong&gt; Admin console clicks do not survive a rebuilt
environment; an idempotent script with &lt;code&gt;--verify&lt;/code&gt; and &lt;code&gt;--rollback&lt;/code&gt; does.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>authentication</category>
      <category>docker</category>
      <category>java</category>
      <category>security</category>
    </item>
  </channel>
</rss>
