Chuyển đến nội dung chính

Lesson 17: Customize the Keycloak interface - Themes

Theme types (Login, Account, Admin, Email, Welcome), theme directory structure, custom theme creation, theme properties file, FreeMarker template engine, override specific templates, i18n messages, using custom CSS/JS, extending themes (parent), deploying themes (standalone, Docker, JAR/SPI), theme caching and theme development workflow.

🔒 DevSecOps — Lesson 17 Lesson 17: Customizing the Keycloak interface - Themes

Keycloak from Basic to Advanced

Part 5: Themes, Events, Security and Vault

xdev.asia

1. Overview of Keycloak Themes

Keycloak allows for complete customization of the user interface through the Themes system. Each theme controls the look and feel of a specific part of Keycloak, from the login page to notification emails.

1.1 Theme Types

Keycloak supports 5 theme types:

Theme TypeDescriptionApplicable page
LoginLogin, registration, password reset interfaceLogin, Register, OTP, Reset Password, Error
AccountUser account management pageAccount Console (v3 React-based)
AdminAdmin Console interfaceAdmin Console (React-based)
EmailTemplate email sent to userVerify Email, Reset Password, Event notifications
WelcomeDefault welcome pageLanding page when accessing root URL

1.2 Default Theme

Keycloak comes with available themes:

  • keycloak — Default theme for Login, Account, Email
  • keycloak.v2 — New Theme for Login (Keycloak 24+), Account Console v3
  • base (deprecated) — Base theme, should no longer be used as parent

2. Theme

folder structure

Each theme is organized according to a standard folder structure:

themes/
└── my-custom-theme/
    ├── login/
    │   ├── theme.properties
    │   ├── resources/
    │   │   ├── css/
    │   │   │   └── styles.css
    │   │   ├── js/
    │   │   │   └── custom.js
    │   │   └── img/
    │   │       └── logo.png
    │   ├── templates/
    │   │   ├── login.ftl
    │   │   ├── register.ftl
    │   │   └── error.ftl
    │   └── messages/
    │       ├── messages_en.properties
    │       └── messages_vi.properties
    ├── account/
    │   ├── theme.properties
    │   └── ...
    └── email/
        ├── theme.properties
        ├── html/
        │   └── email-verification.ftl
        └── text/
            └── email-verification.ftl

2.1 Main folders

FolderPurpose
resources/CSS, JavaScript, static images
templates/FreeMarker template files (.ftl)
messages/File i18n for multilingual

3. Theme Properties File

File theme.properties is the central configuration file of each theme type:

# themes/my-custom-theme/login/theme.properties

# Kế thừa từ theme keycloak (sử dụng tất cả template/resource chưa override)
parent=keycloak.v2

# Import resources từ theme khác (common resources)
import=common/keycloak

# CSS files (thêm custom CSS)
styles=css/login.css css/styles.css

# Locales hỗ trợ
locales=en,vi

# Script files
scripts=js/custom.js

# Cache control (cho production)
cacheControl=max-age=2592000

3.1 Important attributes

PropertyDescriptionExample
parentParent theme to inheritparent=keycloak.v2
importImport resources from another themeimport=common/keycloak
stylesList of CSS files (space-separated)styles=css/login.css css/custom.css
scriptsJavaScript filesscripts=js/app.js
localesSupported languageslocales=en,vi,ja

4. FreeMarker Template Engine

Keycloak uses Apache FreeMarker as the template engine for Login and Email themes. FreeMarker allows dynamic HTML generation with variables and logic.

4.1 Basic syntax

<!-- Biến -->
${realm.displayName}
${url.loginAction}
${messagesPerField.printIfExists('username','has-error')}

<!-- Điều kiện -->
<#if realm.password>
  <!-- Form đăng nhập bằng password -->
</#if>

<#if social.providers??>
  <!-- Hiển thị social login buttons -->
  <#list social.providers as p>
    <a href="${p.loginUrl}">${p.displayName}</a>
  </#list>
</#if>

<!-- Macro -->
<#macro registrationLayout>
  <!-- Layout wrapper -->
</#macro>

4.2 Variables available in Login Theme

VariableDescription
realmRealm information (displayName, registrationAllowed, password, social...)
urlURLs (loginAction, registrationUrl, loginResetCredentialsUrl...)
clientClient is requesting login (name, clientId...)
loginForm data (entered username...)
messageError/success message
messagesPerFieldValidation messages for each field
socialSocial login providers
localeCurrent locale and list of supported locales

5. Override specific Template

You only need to override the templates you want to change. The remaining templates will be inherited from the parent theme.

5.1 Override Login Page

<!-- themes/my-custom-theme/login/templates/login.ftl -->
<#import "template.ftl" as layout>

<@layout.registrationLayout
    displayMessage=!messagesPerField.existsError('username','password')
    displayInfo=realm.password && realm.registrationAllowed
    ; section>

    <#if section = "header">
        <div class="custom-header">
            <img src="${url.resourcesPath}/img/logo.png" alt="Logo" class="login-logo" />
            <h1>${msg("loginAccountTitle")}</h1>
        </div>
    <#elseif section = "form">
        <div class="custom-login-form">
            <form action="${url.loginAction}" method="post">
                <div class="form-group">
                    <label for="username">
                        <#if !realm.loginWithEmailAllowed>
                            ${msg("username")}
                        <#elseif !realm.registrationEmailAsUsername>
                            ${msg("usernameOrEmail")}
                        <#else>
                            ${msg("email")}
                        </#if>
                    </label>
                    <input id="username" name="username" type="text"
                           value="${(login.username!'')}"
                           class="form-control ${messagesPerField.printIfExists('username','has-error')}"
                           autofocus autocomplete="username" />
                    <#if messagesPerField.existsError('username')>
                        <span class="error-message">${messagesPerField.get('username')}</span>
                    </#if>
                </div>

                <div class="form-group">
                    <label for="password">${msg("password")}</label>
                    <input id="password" name="password" type="password"
                           class="form-control ${messagesPerField.printIfExists('password','has-error')}"
                           autocomplete="current-password" />
                    <#if messagesPerField.existsError('password')>
                        <span class="error-message">${messagesPerField.get('password')}</span>
                    </#if>
                </div>

                <div class="form-actions">
                    <#if realm.rememberMe && !usernameHidden??>
                        <label class="remember-me">
                            <input id="rememberMe" name="rememberMe" type="checkbox"
                                   <#if login.rememberMe??>checked</#if>>
                            ${msg("rememberMe")}
                        </label>
                    </#if>

                    <button type="submit" class="btn btn-primary">
                        ${msg("doLogIn")}
                    </button>
                </div>
            </form>

            <#if realm.password && realm.registrationAllowed>
                <div class="register-link">
                    ${msg("noAccount")}
                    <a href="${url.registrationUrl}">${msg("doRegister")}</a>
                </div>
            </#if>
        </div>
    </#if>
</@layout.registrationLayout>

5.2 Override Register Page

<!-- themes/my-custom-theme/login/templates/register.ftl -->
<#import "template.ftl" as layout>

<@layout.registrationLayout
    displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm')
    ; section>

    <#if section = "header">
        <h1>${msg("registerTitle")}</h1>
    <#elseif section = "form">
        <form action="${url.registrationAction}" method="post">
            <div class="form-group">
                <label for="firstName">${msg("firstName")}</label>
                <input id="firstName" name="firstName" type="text"
                       value="${(register.formData.firstName!'')}"
                       class="form-control" />
            </div>

            <div class="form-group">
                <label for="lastName">${msg("lastName")}</label>
                <input id="lastName" name="lastName" type="text"
                       value="${(register.formData.lastName!'')}"
                       class="form-control" />
            </div>

            <div class="form-group">
                <label for="email">${msg("email")}</label>
                <input id="email" name="email" type="email"
                       value="${(register.formData.email!'')}"
                       class="form-control" autocomplete="email" />
            </div>

            <button type="submit" class="btn btn-primary">
                ${msg("doRegister")}
            </button>

            <div class="back-to-login">
                <a href="${url.loginUrl}">${msg("backToLogin")}</a>
            </div>
        </form>
    </#if>
</@layout.registrationLayout>

5.3 Override Error Page

<!-- themes/my-custom-theme/login/templates/error.ftl -->
<#import "template.ftl" as layout>

<@layout.registrationLayout displayMessage=false; section>
    <#if section = "header">
        ${kcSanitize(msg("errorTitle"))?no_esc}
    <#elseif section = "form">
        <div class="error-container">
            <div class="alert alert-error">
                <span class="error-icon">⚠</span>
                ${kcSanitize(message.summary)?no_esc}
            </div>
            <#if skipLink??>
            <#else>
                <#if client?? && client.baseUrl?has_content>
                    <a href="${client.baseUrl}" class="btn btn-primary">
                        ${kcSanitize(msg("backToApplication"))?no_esc}
                    </a>
                </#if>
            </#if>
        </div>
    </#if>
</@layout.registrationLayout>

6. Custom CSS and JavaScript

6.1 Add Custom CSS

/* themes/my-custom-theme/login/resources/css/styles.css */

/* Override login page styles */
.login-pf body {
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
    font-family: 'Inter', -apple-system, sans-serif;
}

/* Custom login card */
#kc-login {
    background: rgba(255, 255, 255, 0.95);
    border-radius: 16px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
    padding: 40px;
    max-width: 440px;
    margin: 0 auto;
}

/* Logo */
.login-logo {
    max-width: 180px;
    margin: 0 auto 24px;
    display: block;
}

/* Form inputs */
.form-control {
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    padding: 12px 16px;
    font-size: 14px;
    transition: border-color 0.2s;
}

.form-control:focus {
    border-color: #0f3460;
    box-shadow: 0 0 0 3px rgba(15, 52, 96, 0.1);
    outline: none;
}

.form-control.has-error {
    border-color: #e74c3c;
}

/* Primary button */
.btn-primary {
    background: #0f3460;
    color: white;
    border: none;
    border-radius: 8px;
    padding: 12px 24px;
    font-size: 16px;
    font-weight: 600;
    width: 100%;
    cursor: pointer;
    transition: background 0.2s;
}

.btn-primary:hover {
    background: #1a4a7a;
}

/* Social login buttons */
.social-links {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-top: 16px;
}

6.2 Add Custom JavaScript

// themes/my-custom-theme/login/resources/js/custom.js

document.addEventListener('DOMContentLoaded', function() {
    // Password visibility toggle
    const passwordField = document.getElementById('password');
    if (passwordField) {
        const toggleBtn = document.createElement('button');
        toggleBtn.type = 'button';
        toggleBtn.className = 'password-toggle';
        toggleBtn.textContent = '👁';
        toggleBtn.addEventListener('click', function() {
            passwordField.type = passwordField.type === 'password' ? 'text' : 'password';
        });
        passwordField.parentNode.appendChild(toggleBtn);
    }

    // Form validation enhancement
    const form = document.querySelector('form');
    if (form) {
        form.addEventListener('submit', function(e) {
            const submitBtn = form.querySelector('button[type="submit"]');
            if (submitBtn) {
                submitBtn.disabled = true;
                submitBtn.textContent = 'Đang xử lý...';
            }
        });
    }
});

7. Internationalization (i18n)

Keycloak supports multiple languages ​​through the file messages_{locale}.properties.

7.1 Create Vietnamese file

# themes/my-custom-theme/login/messages/messages_vi.properties

# Login form
loginAccountTitle=Đăng nhập vào tài khoản
username=Tên đăng nhập
usernameOrEmail=Tên đăng nhập hoặc Email
password=Mật khẩu
rememberMe=Ghi nhớ đăng nhập
doLogIn=Đăng nhập
noAccount=Chưa có tài khoản?
doRegister=Đăng ký ngay

# Registration
registerTitle=Tạo tài khoản mới
firstName=Họ
lastName=Tên
email=Địa chỉ email
doRegister=Đăng ký

# Errors
invalidUserMessage=Tên đăng nhập hoặc mật khẩu không đúng.
accountDisabledMessage=Tài khoản đã bị vô hiệu hóa. Vui lòng liên hệ quản trị viên.

# Password reset
emailForgotTitle=Quên mật khẩu?
backToLogin=Quay lại đăng nhập

# Custom messages
welcomeMessage=Chào mừng bạn đến với hệ thống
companyName=Công ty của bạn

7.2 Override English messages

# themes/my-custom-theme/login/messages/messages_en.properties

# Override default messages
loginAccountTitle=Sign in to your account
welcomeMessage=Welcome to our platform
companyName=Your Company

# Custom messages cho branding
loginTitleHtml=<strong>Welcome</strong> to {0}

7.3 Using i18n in template

<!-- Sử dụng message key -->
<h1>${msg("loginAccountTitle")}</h1>

<!-- Message với tham số -->
<p>${msg("loginTitleHtml", realm.displayName)}</p>

<!-- Kiểm tra message tồn tại -->
<#if msg("welcomeMessage")?has_content>
    <p class="welcome">${msg("welcomeMessage")}</p>
</#if>

<!-- Locale selector -->
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
    <div class="locale-selector">
        <#list locale.supported as l>
            <a href="${l.url}" class="${(l.label == locale.current)?then('active','')}">
                ${l.label}
            </a>
        </#list>
    </div>
</#if>

8. Extending Themes with Parent

Theme inheritance allows creating new themes based on existing themes, only overriding the parts that need to be changed.

8.1 How it works

Thứ tự tìm kiếm resource/template:
1. Current theme (my-custom-theme/login/)
2. Parent theme (keycloak.v2/login/)
3. Parent của parent (nếu có)
4. Base resources (import)

8.2 Multi-level Inheritance

# themes/company-base/login/theme.properties
parent=keycloak.v2
styles=css/login.css css/company-base.css
locales=en,vi

# themes/company-product-a/login/theme.properties
parent=company-base
styles=css/login.css css/company-base.css css/product-a.css

8.3 Theme Variants

From Keycloak 24+, you can define theme variants to have multiple versions of the same theme:

# theme.properties
parent=keycloak.v2

# Variant definition (admin có thể chọn trong Realm Settings)
variant.light.styles=css/login.css css/light.css
variant.dark.styles=css/login.css css/dark.css

9. Deploying Themes

9.1 Standalone Deployment

Copy theme to Keycloak's themes/ folder:

# Cấu trúc standalone
$KEYCLOAK_HOME/
├── themes/
│   └── my-custom-theme/
│       ├── login/
│       │   ├── theme.properties
│       │   ├── resources/
│       │   ├── templates/
│       │   └── messages/
│       └── email/
│           └── ...

9.2 Docker Deployment

# Dockerfile - Simple COPY
FROM quay.io/keycloak/keycloak:26.1 AS builder

# Copy custom theme
COPY themes/my-custom-theme /opt/keycloak/themes/my-custom-theme

# Build optimized Keycloak
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.1

COPY --from=builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

9.3 Docker Multi-stage Build with Node (Account v3)

# Dockerfile - Multi-stage build cho Account Console v3 customization
FROM node:20-alpine AS theme-builder

WORKDIR /theme
COPY account-theme/ .
RUN npm ci && npm run build

FROM quay.io/keycloak/keycloak:26.1 AS keycloak-builder

# Copy login/email themes
COPY themes/my-custom-theme /opt/keycloak/themes/my-custom-theme

# Copy built Account v3 theme
COPY --from=theme-builder /theme/dist /opt/keycloak/themes/my-custom-theme/account

RUN /opt/keycloak/bin/kc.sh build

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

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

9.4 JAR/SPI Deployment

Package the theme as a JAR file to deploy as SPI provider:

my-theme.jar
├── META-INF/
│   └── keycloak-themes.json
└── theme/
    └── my-custom-theme/
        ├── login/
        └── email/
// META-INF/keycloak-themes.json
{
    "themes": [
        {
            "name": "my-custom-theme",
            "types": ["login", "email"]
        }
    ]
}
# Deploy JAR
cp my-theme.jar $KEYCLOAK_HOME/providers/
$KEYCLOAK_HOME/bin/kc.sh build

10. Theme Development Workflow

10.1 Turn off cache during development

Default Keycloak cache themes, need to turn off when developing:

# Tắt theme caching cho development
bin/kc.sh start-dev \
  --spi-theme-static-max-age=-1 \
  --spi-theme-cache-themes=false \
  --spi-theme-cache-templates=false

10.2 Docker Compose cho Development

# docker-compose.dev.yml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.1
    command:
      - start-dev
      - --spi-theme-static-max-age=-1
      - --spi-theme-cache-themes=false
      - --spi-theme-cache-templates=false
    volumes:
      # Mount theme directory cho hot-reload
      - ./themes/my-custom-theme:/opt/keycloak/themes/my-custom-theme
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"
    depends_on:
      - postgres

  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

10.3 Development Workflow

# 1. Khởi chạy dev environment
docker compose -f docker-compose.dev.yml up -d

# 2. Chỉnh sửa file theme (CSV, FTL, properties)
# Thay đổi sẽ tự động reflect (vì cache đã tắt)

# 3. Refresh browser để xem thay đổi
# Không cần restart Keycloak!

# 4. Áp dụng theme vào Realm
# Admin Console → Realm Settings → Themes → Login Theme: my-custom-theme

11. React Account Console v3 Customization

Account Console v3 uses React + PatternFly. To customize, you need to build custom React app.

11.1 Keycloakify (Recommended)

Use Keycloakify — a specialized framework for creating Keycloak themes with React:

# Tạo project mới với Keycloakify
npx create-keycloakify-project my-keycloak-theme
cd my-keycloak-theme

# Cấu trúc project
my-keycloak-theme/
├── src/
│   ├── login/          # Login theme pages
│   │   ├── KcPage.tsx
│   │   ├── pages/
│   │   │   ├── Login.tsx
│   │   │   ├── Register.tsx
│   │   │   └── ...
│   │   └── i18n.ts
│   └── account/        # Account theme pages
├── public/
│   └── ...
├── package.json
└── vite.config.ts

# Dev mode
npm run dev

# Build JAR
npm run build-keycloak-theme
# Output: dist_keycloak/my-keycloak-theme.jar

11.2 Keycloakify Login Page Example

// src/login/pages/Login.tsx
import { type PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";

export default function Login(props: PageProps<Extract<KcContext, { pageId: "login.ftl" }>, I18n>) {
    const { kcContext, i18n, Template } = props;
    const { url, realm, social, login } = kcContext;
    const { msg } = i18n;

    return (
        <Template kcContext={kcContext} i18n={i18n}>
            <div className="custom-login">
                <img src={`${url.resourcesPath}/img/logo.png`} alt="Logo" />
                <h1>{msg("loginAccountTitle")}</h1>

                <form action={url.loginAction} method="post">
                    <input
                        name="username"
                        defaultValue={login.username ?? ""}
                        placeholder={msg("usernameOrEmail")}
                    />
                    <input
                        name="password"
                        type="password"
                        placeholder={msg("password")}
                    />
                    <button type="submit">{msg("doLogIn")}</button>
                </form>

                {social?.providers && (
                    <div className="social-providers">
                        {social.providers.map(p => (
                            <a key={p.alias} href={p.loginUrl}>
                                {p.displayName}
                            </a>
                        ))}
                    </div>
                )}
            </div>
        </Template>
    );
}

12. Email Theme Customization

Email themes come in two versions per template: HTML and plain text.

<!-- themes/my-custom-theme/email/html/email-verification.ftl -->
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
    <div style="background: #0f3460; padding: 20px; text-align: center;">
        <img src="${url.resourcesPath}/img/logo-white.png" alt="Logo" style="max-width: 150px;" />
    </div>
    <div style="padding: 30px; background: #ffffff;">
        <h2>${msg("emailVerificationSubject")}</h2>
        <p>${msg("emailVerificationBody", linkExpiration, realmName)}</p>
        <a href="${link}" style="display: inline-block; background: #0f3460; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px;">
            ${msg("emailVerificationLinkText")}
        </a>
    </div>
    <div style="padding: 15px; text-align: center; color: #888; font-size: 12px;">
        <p>${msg("emailFooter")}</p>
    </div>
</body>
</html>
<!-- themes/my-custom-theme/email/text/email-verification.ftl -->
${msg("emailVerificationSubject")}

${msg("emailVerificationBodyPlainText", link, linkExpiration, realmName)}

13. Apply Theme to Realm

13.1 Qua Admin Console

  1. Log in to Admin Console
  2. Select Realm to configure
  3. Go to Realm Settings → Themes
  4. Choose a theme for each type:
    • Login Theme: my-custom-theme
    • Account Theme: my-custom-theme
    • Admin Console Theme: (keep default)
    • Email Theme: my-custom-theme
  5. Press Save

13.2 Qua REST API

# Cập nhật theme cho realm qua API
curl -X PUT "http://localhost:8080/admin/realms/my-realm" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "loginTheme": "my-custom-theme",
    "accountTheme": "my-custom-theme",
    "emailTheme": "my-custom-theme"
  }'

14. Best Practices

  • Always use parent theme — Inherit from keycloak.v2 instead of writing from scratch. Ensure compatibility when upgrading Keycloak.
  • Only override what needs to be changed — Do not copy the entire template. Only create files for the template you want to customize.
  • Separate themes by client — If there are multiple applications, you can use a different theme for each client (Realm Settings or Client-level theme override).
  • Test on multiple browsers — Especially the login page must work on mobile.
  • Use i18n — Always use message keys instead of hardcoded text in templates.
  • Version control themes — Manage themes in separate Git repo or same project.
  • CI/CD pipeline — Build JAR theme in CI, test with Keycloak container, deploy automatically.