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 Type | Description | Applicable page |
|---|---|---|
| Login | Login, registration, password reset interface | Login, Register, OTP, Reset Password, Error |
| Account | User account management page | Account Console (v3 React-based) |
| Admin | Admin Console interface | Admin Console (React-based) |
| Template email sent to user | Verify Email, Reset Password, Event notifications | |
| Welcome | Default welcome page | Landing 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 structureEach 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
| Folder | Purpose |
|---|---|
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
| Property | Description | Example |
|---|---|---|
parent | Parent theme to inherit | parent=keycloak.v2 |
import | Import resources from another theme | import=common/keycloak |
styles | List of CSS files (space-separated) | styles=css/login.css css/custom.css |
scripts | JavaScript files | scripts=js/app.js |
locales | Supported languages | locales=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
| Variable | Description |
|---|---|
realm | Realm information (displayName, registrationAllowed, password, social...) |
url | URLs (loginAction, registrationUrl, loginResetCredentialsUrl...) |
client | Client is requesting login (name, clientId...) |
login | Form data (entered username...) |
message | Error/success message |
messagesPerField | Validation messages for each field |
social | Social login providers |
locale | Current 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
- Log in to Admin Console
- Select Realm to configure
- Go to Realm Settings → Themes
- 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
- Login Theme:
- 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.v2instead 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.