Authentication Error Handling in Keycloak: Customizing Error Pages and User Experience
When a user fails to log in, the error message they see shapes their next action. A vague “Something went wrong” leaves them stranded. An overly detailed “No account found for [email protected]” hands information to attackers. Keycloak gives you the tools to strike the right balance, but its default error pages are generic and unbranded.
This guide walks through how Keycloak handles authentication errors, how to customize error pages with FreeMarker themes, how to handle OAuth/OIDC errors in your application, and how to configure brute force protection to keep accounts safe. For an overview of Keycloak’s authentication concepts, see the Skycloak documentation.
How Keycloak Handles Authentication Errors
Keycloak uses a three-layer error handling system: login form errors are rendered inline via FreeMarker templates, OAuth/OIDC errors are redirected to the client application as query parameters, and unrecoverable errors are shown on a standalone error page. Understanding which layer handles each error type is essential for providing consistent user experience across your application.
Depending on where in the authentication flow an error occurs, Keycloak responds differently.
Login Form Errors
When a user submits invalid credentials through the Keycloak login form, Keycloak re-renders the login page with an error message. The default message is intentionally vague: “Invalid username or password.” This prevents username enumeration, where an attacker probes to discover which usernames exist.
Keycloak passes errors to its FreeMarker templates through a message object with two key properties:
message.summary— the human-readable error textmessage.type— the severity level (error,warning,info, orsuccess)
OAuth/OIDC Error Redirects
For errors that occur during an OAuth 2.0 or OpenID Connect flow, Keycloak redirects the user back to the client application’s redirect URI with error parameters in the query string. These follow the OAuth 2.0 specification:
https://app.example.com/callback?error=access_denied&error_description=User+denied+consent
Common error codes include:
| Error Code | Meaning |
|---|---|
invalid_request |
Malformed authorization request |
unauthorized_client |
Client not authorized for this grant type |
access_denied |
User denied consent or authentication failed |
login_required |
Session expired and re-authentication is needed |
interaction_required |
User interaction needed (e.g., consent screen) |
invalid_grant |
Authorization code expired or already used |
General Error Pages
For errors that cannot be tied back to a specific client (invalid redirect URI, misconfigured realm, server errors), Keycloak renders a standalone error page. This is the error.ftl template in the active theme.
Common Keycloak Error Scenarios
Understanding the most frequent error scenarios helps you plan your customizations.
Invalid Credentials
The most common error. Keycloak returns the same message whether the username or the password is wrong, which is the correct security behavior. Never customize this to reveal which field was incorrect.
Expired Sessions
Users who leave a login tab open and return later will encounter an expired session. Keycloak shows an “Action expired” or “Session expired” message. This is also common in flows where the user opens a login link in a different browser tab.
Account Locked
When brute force protection is enabled (covered below), Keycloak temporarily locks accounts after too many failed attempts. The default message is “Account is temporarily disabled,” though this can be configured to show a more generic error to avoid confirming that the account exists.
Consent Denied
In flows that require explicit user consent, if the user clicks “No” on the consent screen, Keycloak redirects back to the application with error=access_denied.
Identity Provider Errors
When using identity brokering (Login with Google, SAML federation, etc.), errors from the external IdP propagate through Keycloak. You can debug SAML-related errors using the SAML Decoder tool. Common failures include misconfigured redirect URIs on the external IdP, expired SAML assertions, or the user canceling the external login.
Account Not Fully Set Up
If required actions are configured (verify email, update password, configure OTP) and the user abandons the flow, subsequent login attempts may trigger these required actions again. Users often mistake this for an error. See our guide on configuring MFA in Keycloak for details on managing OTP-related required actions.
Customizing Error Pages with Keycloak Themes
Keycloak themes use Apache FreeMarker templates. You create a custom theme by extending the built-in base or keycloak theme and overriding specific templates.
Theme Directory Structure
A custom login theme lives in the Keycloak themes directory:
themes/
my-company/
login/
theme.properties
template.ftl
login.ftl
error.ftl
messages/
messages_en.properties
resources/
css/
custom.css
img/
logo.png
Configuring the Theme
The theme.properties file declares the parent theme and custom resources:
parent=keycloak
import=common/keycloak
styles=css/custom.css
By extending the keycloak theme, you inherit all existing templates and only override the ones you need to change.
Customizing the Base Template
The template.ftl file wraps all login pages. Override it to add your branding, custom CSS, and layout:
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${msg("loginTitle",(realm.displayName!''))}</title>
<link rel="stylesheet" href="${url.resourcesPath}/css/custom.css">
<#if properties.favicon?has_content>
<link rel="icon" href="${url.resourcesPath}/${properties.favicon}">
</#if>
</head>
<body class="login-page ${bodyClass}">
<div class="login-container">
<div class="login-header">
<img src="${url.resourcesPath}/img/logo.png" alt="Company Logo" class="login-logo">
<h1>${msg("loginTitleHtml",(realm.displayNameHtml!''))}</h1>
</div>
<div class="login-content">
<#if displayMessage && message?has_content>
<div class="alert alert-${message.type}">
<#if message.type = 'error'>
<svg class="alert-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</#if>
<span>${kcSanitize(message.summary)?no_esc}</span>
</div>
</#if>
<#nested "form">
</div>
<#if displayInfo>
<div class="login-info">
<#nested "info">
</div>
</#if>
</div>
</body>
</html>
</#macro>
Creating a Custom Error Page
The error.ftl template handles standalone errors that are not tied to a specific login form. Override it to match your branding and provide helpful next steps:
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=false bodyClass="error-page"; section>
<#if section = "form">
<div class="error-container">
<div class="error-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<h2>${msg("errorTitle")}</h2>
<#if message?has_content>
<p class="error-message">${kcSanitize(message.summary)?no_esc}</p>
</#if>
<div class="error-actions">
<#if client?? && client.baseUrl?has_content>
<a href="${client.baseUrl}" class="btn btn-primary">
${msg("backToApplication")}
</a>
</#if>
<a href="${url.loginUrl}" class="btn btn-secondary">
${msg("doTryAgain", "Try Again")}
</a>
</div>
</div>
</#if>
</@layout.registrationLayout>
Customizing Error Messages
Override the default messages by creating a messages/messages_en.properties file:
errorTitle=Something went wrong
backToApplication=Return to Application
doTryAgain=Try Again
# Override default login error messages
invalidUserMessage=The username or password you entered is incorrect.
accountTemporarilyDisabledMessage=Your account has been temporarily locked. Please try again later.
expiredActionMessage=This action has expired. Please restart the login process.
accountDisabledMessage=Your account has been disabled. Please contact support.
Note that the locked account message above does not reveal how long the lockout lasts. Telling an attacker “try again in 5 minutes” gives them a countdown.
Handling OAuth/OIDC Errors in Your Application
Your application also needs to handle errors returned by Keycloak during the OAuth/OIDC flow. When Keycloak redirects back to your application with an error, you need to parse the error parameters and display an appropriate message.
JavaScript/TypeScript Error Handler
Here is a practical handler for an application using the Authorization Code flow:
interface OIDCError {
error: string;
error_description?: string;
}
function parseOIDCError(url: URL): OIDCError | null {
const error = url.searchParams.get("error");
if (!error) return null;
return {
error,
error_description: url.searchParams.get("error_description") ?? undefined,
};
}
const ERROR_MESSAGES: Record<string, string> = {
access_denied: "Access was denied. Please try logging in again.",
login_required: "Your session has expired. Please log in again.",
interaction_required: "Additional verification is required. Please log in again.",
invalid_request: "There was a problem with the login request. Please try again.",
unauthorized_client: "This application is not configured correctly. Please contact support.",
server_error: "The authentication server encountered an error. Please try again later.",
};
function handleAuthCallback(): void {
const url = new URL(window.location.href);
const oidcError = parseOIDCError(url);
if (oidcError) {
// Log the raw error for debugging (server-side only in production)
console.error("OIDC error:", oidcError.error, oidcError.error_description);
// Show a user-friendly message
const userMessage =
ERROR_MESSAGES[oidcError.error] ||
"An unexpected error occurred during login. Please try again.";
displayError(userMessage);
// Clean the URL to remove error parameters
window.history.replaceState({}, "", url.pathname);
return;
}
// Handle successful authentication...
const code = url.searchParams.get("code");
if (code) {
exchangeCodeForTokens(code);
}
}
Key Principles for Application-Side Error Handling
Never display raw error descriptions to users. The error_description parameter may contain internal details from Keycloak that you do not want to expose. Map error codes to your own user-friendly messages.
Log errors server-side. Send the raw error code and description to your logging system for debugging, but keep them out of the UI.
Clean up the URL. After processing an error callback, remove the error parameters from the browser URL. Users who share URLs or bookmark the page should not re-trigger error states.
Handle the state parameter. Always validate the state parameter to prevent CSRF attacks, even on error callbacks.
Security Best Practices for Error Handling
Do Not Leak Information
The most important rule: error messages should not help attackers. Specifically:
- Never reveal whether a username exists in the system. Use the same message for “wrong username” and “wrong password.”
- Never disclose lockout duration or remaining attempts. Messages like “2 attempts remaining” let attackers calibrate their attacks.
- Never expose internal error details, stack traces, or Keycloak version numbers in error pages.
Rate Limiting at Multiple Layers
Keycloak’s built-in brute force protection is your first layer, but you should also consider:
- Reverse proxy rate limiting — Use nginx, Apache, or a CDN to limit the number of requests to Keycloak’s login endpoints per IP address.
- Application-level throttling — If your application initiates login flows programmatically, throttle how frequently it redirects users to Keycloak.
Account Lockout Policies
Design lockout policies that balance security with usability. A permanent lockout after 3 attempts creates a denial-of-service vector where attackers intentionally lock out legitimate users. A temporary lockout with progressive backoff is more resilient.
Configuring Brute Force Protection in Keycloak
Keycloak includes a built-in brute force detection system that you configure at the realm level.
Enabling Brute Force Detection
In the Keycloak Admin Console:
- Navigate to your realm settings.
- Select the Security Defenses tab.
- Open the Brute Force Detection section.
- Toggle the feature on.
Configuration Options
| Setting | Recommended Value | Purpose |
|---|---|---|
| Max Login Failures | 5 | Failures before lockout triggers |
| Wait Increment | 60 seconds | Initial lockout duration |
| Max Wait | 900 seconds (15 min) | Maximum lockout duration |
| Failure Reset Time | 600 seconds (10 min) | Time before failure counter resets |
| Quick Login Check (ms) | 1000 | Minimum time between logins |
| Minimum Quick Login Wait | 60 seconds | Lockout for too-fast login attempts |
How Progressive Lockout Works
With the settings above, Keycloak implements progressive backoff:
- Attempts 1-5: Normal login attempts allowed.
- After 5 failures: Account locked for 60 seconds.
- Continued failures: Lockout duration increases, up to the 15-minute maximum.
- After 10 minutes of no failures: The failure counter resets.
The quick login check catches automated attacks that submit credentials faster than a human can type. If two login attempts for the same account arrive within 1 second, Keycloak immediately applies a 60-second lockout.
Permanent vs. Temporary Lockout
Keycloak also supports permanent lockout, where an administrator must manually unlock the account. While this is more secure against sustained attacks, it creates an operational burden and a denial-of-service risk. For most applications, temporary lockout with progressive backoff provides sufficient protection without the administrative overhead.
Monitoring Brute Force Events
Keycloak emits events for failed login attempts and account lockouts. Configure an event listener to forward these to your monitoring system:
LOGIN_ERROR— fired on each failed login attemptUSER_DISABLED_BY_TEMPORARY_LOCKOUT— fired when brute force protection locks an account
Feed these events into your SIEM or alerting system to detect coordinated attacks across multiple accounts, which individual account lockouts may not catch. For a complete guide to monitoring these events with Prometheus and Grafana, see monitoring Keycloak with Prometheus and Grafana.
Keycloak’s audit logging feature records all authentication events and admin operations, making it straightforward to investigate brute force patterns after the fact. For more on Keycloak audit logging, see auditing best practices for Keycloak security.
Putting It All Together
A well-implemented error handling strategy for Keycloak covers three layers:
- Keycloak themes provide branded, user-friendly error pages that guide users without leaking information.
- Application-side handling catches OAuth/OIDC errors and translates them into helpful messages within your app’s UI.
- Brute force protection automatically limits the damage from credential stuffing and password spraying attacks.
Each layer reinforces the others. Custom error pages reduce support tickets by giving users clear next steps. Application-side handling ensures errors in the OAuth flow do not leave users on a broken callback page. Brute force protection runs silently in the background, keeping accounts safe without adding friction to normal usage.
If you are running Keycloak on Skycloak, the managed theme library lets you deploy custom login themes without building and deploying a Keycloak distribution. You can upload your branded templates and CSS directly, and Skycloak handles the theme deployment across your cluster.
Ready to simplify your Keycloak deployment and focus on building your application? Explore Skycloak’s plans.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.