Keycloak Event Streaming and Webhooks: Real-Time Integration
Last updated: March 2026
Keycloak generates events for everything that happens in your identity system: user logins, failed authentication attempts, password changes, admin operations, token grants, and more. These events are a goldmine for building reactive integrations, but Keycloak’s default event handling (database storage and JBoss logging) only scratches the surface. To build real-time integrations, you need to stream these events to external systems.
This guide covers Keycloak’s event system from the ground up: the built-in event types, the Event Listener SPI for custom processing, building a webhook provider in Java, integrating with Kafka and RabbitMQ, and practical patterns for event filtering and routing.
Understanding Keycloak Events
Keycloak emits two categories of events, each with different structures and use cases.
User Events
User events capture end-user authentication activity. These are the events you will use for security monitoring, user analytics, and reactive workflows.
Common user event types:
| Event Type | When It Fires |
|---|---|
LOGIN |
User successfully authenticates |
LOGIN_ERROR |
Authentication attempt fails |
REGISTER |
New user registers |
LOGOUT |
User logs out |
CODE_TO_TOKEN |
Authorization code exchanged for tokens |
CODE_TO_TOKEN_ERROR |
Code exchange fails |
REFRESH_TOKEN |
Token refreshed |
UPDATE_PASSWORD |
User changes password |
UPDATE_PROFILE |
User updates profile |
VERIFY_EMAIL |
User verifies email address |
CUSTOM_REQUIRED_ACTION |
Custom required action completed |
GRANT_CONSENT |
User grants consent to a client |
REVOKE_GRANT |
User revokes consent |
SEND_RESET_PASSWORD |
Password reset email sent |
A user event looks like this:
{
"time": 1747224000000,
"type": "LOGIN",
"realmId": "my-app",
"realmName": "my-app",
"clientId": "frontend-app",
"userId": "user-uuid-123",
"sessionId": "session-uuid-456",
"ipAddress": "192.168.1.100",
"details": {
"auth_method": "openid-connect",
"auth_type": "code",
"redirect_uri": "https://app.example.com/callback",
"consent": "no_consent_required",
"code_id": "code-uuid-789",
"username": "[email protected]"
}
}
Admin Events
Admin events capture changes made through the Admin Console or Admin REST API. These are critical for audit trails and compliance.
| Operation Type | Examples |
|---|---|
CREATE |
New user, client, role, group created |
UPDATE |
User attributes modified, client settings changed |
DELETE |
User deleted, role removed |
ACTION |
Password reset by admin, user enabled/disabled |
An admin event looks like this:
{
"time": 1747224000000,
"operationType": "UPDATE",
"realmId": "my-app",
"realmName": "my-app",
"authDetails": {
"realmId": "master",
"clientId": "admin-cli",
"userId": "admin-uuid",
"ipAddress": "10.0.0.1"
},
"resourceType": "USER",
"resourcePath": "users/user-uuid-123",
"representation": "{"firstName":"Alice","lastName":"Smith"}"
}
For a comprehensive guide to Keycloak auditing, see our post on auditing best practices. You can also explore Keycloak’s audit log features to understand what is captured out of the box.
Enabling Events in Keycloak
Before you can process events, make sure they are enabled in your realm:
- Go to Realm Settings > Events
- Under User events settings, toggle Save events to On
- Select which event types to save (or leave all selected)
- Set Expiration for how long to retain events in the database
- Under Admin events settings, toggle Save events to On
- Optionally enable Include representation to capture the full resource state
The Event Listener SPI
Keycloak’s Event Listener SPI is the extension point for custom event processing. Every event passes through all registered event listeners, allowing you to send events to any external system.
SPI Architecture

Each listener implements two interfaces:
EventListenerProvider— handles individual eventsEventListenerProviderFactory— creates listener instances and manages configuration
Building a Webhook Event Listener
Here is a complete webhook event listener that sends Keycloak events to an HTTP endpoint with retry logic and error handling.
Project Setup
Create a Maven project for the SPI:
<!-- pom.xml -->
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>keycloak-webhook-listener</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<keycloak.version>26.0.0</keycloak.version>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
The Event Listener Provider
// src/main/java/com/example/webhook/WebhookEventListenerProvider.java
package com.example.webhook;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventType;
import org.keycloak.events.admin.AdminEvent;
import org.jboss.logging.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
public class WebhookEventListenerProvider implements EventListenerProvider {
private static final Logger log = Logger.getLogger(WebhookEventListenerProvider.class);
private static final ObjectMapper mapper = new ObjectMapper();
private final HttpClient httpClient;
private final String webhookUrl;
private final String webhookSecret;
private final Set<EventType> includedEventTypes;
private final int maxRetries;
public WebhookEventListenerProvider(
String webhookUrl,
String webhookSecret,
Set<EventType> includedEventTypes,
int maxRetries) {
this.webhookUrl = webhookUrl;
this.webhookSecret = webhookSecret;
this.includedEventTypes = includedEventTypes;
this.maxRetries = maxRetries;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
@Override
public void onEvent(Event event) {
// Filter events if specific types are configured
if (!includedEventTypes.isEmpty()
&& !includedEventTypes.contains(event.getType())) {
return;
}
try {
ObjectNode payload = mapper.createObjectNode();
payload.put("event_type", "user_event");
payload.put("type", event.getType().toString());
payload.put("realm_id", event.getRealmId());
payload.put("realm_name", event.getRealmName());
payload.put("client_id", event.getClientId());
payload.put("user_id", event.getUserId());
payload.put("session_id", event.getSessionId());
payload.put("ip_address", event.getIpAddress());
payload.put("timestamp", Instant.ofEpochMilli(event.getTime()).toString());
if (event.getDetails() != null) {
ObjectNode details = mapper.createObjectNode();
event.getDetails().forEach(details::put);
payload.set("details", details);
}
sendWebhookAsync(payload.toString());
} catch (Exception e) {
log.error("Failed to process user event for webhook", e);
}
}
@Override
public void onEvent(AdminEvent event, boolean includeRepresentation) {
try {
ObjectNode payload = mapper.createObjectNode();
payload.put("event_type", "admin_event");
payload.put("operation_type", event.getOperationType().toString());
payload.put("realm_id", event.getRealmId());
payload.put("realm_name", event.getRealmName());
payload.put("resource_type", event.getResourceType().toString());
payload.put("resource_path", event.getResourcePath());
payload.put("timestamp",
Instant.ofEpochMilli(event.getTime()).toString());
if (event.getAuthDetails() != null) {
ObjectNode authDetails = mapper.createObjectNode();
authDetails.put("client_id", event.getAuthDetails().getClientId());
authDetails.put("user_id", event.getAuthDetails().getUserId());
authDetails.put("ip_address", event.getAuthDetails().getIpAddress());
payload.set("auth_details", authDetails);
}
if (includeRepresentation && event.getRepresentation() != null) {
payload.put("representation", event.getRepresentation());
}
sendWebhookAsync(payload.toString());
} catch (Exception e) {
log.error("Failed to process admin event for webhook", e);
}
}
private void sendWebhookAsync(String payload) {
CompletableFuture.runAsync(() -> sendWithRetry(payload, 0));
}
private void sendWithRetry(String payload, int attempt) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(webhookUrl))
.header("Content-Type", "application/json")
.header("User-Agent", "Keycloak-Webhook/1.0")
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(payload));
// Add HMAC signature for verification
if (webhookSecret != null && !webhookSecret.isEmpty()) {
String signature = HmacUtils.computeHmacSha256(webhookSecret, payload);
requestBuilder.header("X-Webhook-Signature", "sha256=" + signature);
}
HttpResponse<String> response = httpClient.send(
requestBuilder.build(),
HttpResponse.BodyHandlers.ofString()
);
if (response.statusCode() >= 200 && response.statusCode() < 300) {
log.debugf("Webhook delivered successfully (status %d)", response.statusCode());
} else if (response.statusCode() >= 500 && attempt < maxRetries) {
// Retry on server errors with exponential backoff
long delay = (long) Math.pow(2, attempt) * 1000;
log.warnf("Webhook returned %d, retrying in %dms (attempt %d/%d)",
response.statusCode(), delay, attempt + 1, maxRetries);
Thread.sleep(delay);
sendWithRetry(payload, attempt + 1);
} else {
log.errorf("Webhook delivery failed with status %d: %s",
response.statusCode(), response.body());
}
} catch (Exception e) {
if (attempt < maxRetries) {
long delay = (long) Math.pow(2, attempt) * 1000;
log.warnf("Webhook request failed, retrying in %dms: %s",
delay, e.getMessage());
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
sendWithRetry(payload, attempt + 1);
} else {
log.errorf("Webhook delivery failed after %d attempts: %s",
maxRetries, e.getMessage());
}
}
}
@Override
public void close() {
// No resources to clean up
}
}
The Provider Factory
// src/main/java/com/example/webhook/WebhookEventListenerProviderFactory.java
package com.example.webhook;
import org.keycloak.Config;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import java.util.HashSet;
import java.util.Set;
public class WebhookEventListenerProviderFactory implements EventListenerProviderFactory {
private String webhookUrl;
private String webhookSecret;
private Set<EventType> includedEventTypes;
private int maxRetries;
@Override
public String getId() {
return "webhook";
}
@Override
public void init(Config.Scope config) {
this.webhookUrl = config.get("url", "http://localhost:3000/webhook");
this.webhookSecret = config.get("secret", "");
this.maxRetries = config.getInt("maxRetries", 3);
// Parse included event types
this.includedEventTypes = new HashSet<>();
String eventTypesStr = config.get("eventTypes", "");
if (!eventTypesStr.isEmpty()) {
for (String type : eventTypesStr.split(",")) {
try {
includedEventTypes.add(EventType.valueOf(type.trim()));
} catch (IllegalArgumentException e) {
// Skip invalid event types
}
}
}
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// No post-initialization needed
}
@Override
public EventListenerProvider create(KeycloakSession session) {
return new WebhookEventListenerProvider(
webhookUrl, webhookSecret, includedEventTypes, maxRetries);
}
@Override
public void close() {
// No resources to clean up
}
}
SPI Service Registration
Create the service file so Keycloak discovers the provider:
# src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactory
com.example.webhook.WebhookEventListenerProviderFactory
Building and Deploying
mvn clean package
# Copy the JAR to Keycloak's providers directory
cp target/keycloak-webhook-listener-1.0.0.jar /opt/keycloak/providers/
# Rebuild Keycloak
/opt/keycloak/bin/kc.sh build
Configuration via Environment Variables
Configure the webhook listener in Keycloak’s startup:
# In your Keycloak startup command or environment
KC_SPI_EVENTS_LISTENER_WEBHOOK_URL=https://api.example.com/keycloak-events
KC_SPI_EVENTS_LISTENER_WEBHOOK_SECRET=your-hmac-secret
KC_SPI_EVENTS_LISTENER_WEBHOOK_MAX_RETRIES=3
KC_SPI_EVENTS_LISTENER_WEBHOOK_EVENT_TYPES=LOGIN,LOGIN_ERROR,REGISTER,LOGOUT
Then enable the listener in the Keycloak Admin Console under Realm Settings > Events > Event listeners, and add webhook to the list.
Kafka Integration
For high-throughput environments, streaming events to Apache Kafka provides durability, ordering, and the ability to fan out events to multiple consumers.
Kafka Event Listener Provider
// KafkaEventListenerProvider.java
package com.example.kafka;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.admin.AdminEvent;
import org.jboss.logging.Logger;
import java.time.Instant;
public class KafkaEventListenerProvider implements EventListenerProvider {
private static final Logger log = Logger.getLogger(KafkaEventListenerProvider.class);
private static final ObjectMapper mapper = new ObjectMapper();
private final KafkaProducer<String, String> producer;
private final String userEventsTopic;
private final String adminEventsTopic;
public KafkaEventListenerProvider(
KafkaProducer<String, String> producer,
String userEventsTopic,
String adminEventsTopic) {
this.producer = producer;
this.userEventsTopic = userEventsTopic;
this.adminEventsTopic = adminEventsTopic;
}
@Override
public void onEvent(Event event) {
try {
ObjectNode payload = mapper.createObjectNode();
payload.put("type", event.getType().toString());
payload.put("realm", event.getRealmName());
payload.put("user_id", event.getUserId());
payload.put("client_id", event.getClientId());
payload.put("ip_address", event.getIpAddress());
payload.put("timestamp", Instant.ofEpochMilli(event.getTime()).toString());
if (event.getDetails() != null) {
ObjectNode details = mapper.createObjectNode();
event.getDetails().forEach(details::put);
payload.set("details", details);
}
// Use userId as the partition key for ordering
String key = event.getUserId() != null ? event.getUserId() : event.getClientId();
ProducerRecord<String, String> record = new ProducerRecord<>(
userEventsTopic, key, payload.toString());
producer.send(record, (metadata, exception) -> {
if (exception != null) {
log.error("Failed to send event to Kafka", exception);
} else {
log.debugf("Event sent to topic %s partition %d offset %d",
metadata.topic(), metadata.partition(), metadata.offset());
}
});
} catch (Exception e) {
log.error("Failed to process event for Kafka", e);
}
}
@Override
public void onEvent(AdminEvent event, boolean includeRepresentation) {
try {
ObjectNode payload = mapper.createObjectNode();
payload.put("operation", event.getOperationType().toString());
payload.put("realm", event.getRealmName());
payload.put("resource_type", event.getResourceType().toString());
payload.put("resource_path", event.getResourcePath());
payload.put("timestamp", Instant.ofEpochMilli(event.getTime()).toString());
String key = event.getAuthDetails() != null
? event.getAuthDetails().getUserId()
: "system";
producer.send(new ProducerRecord<>(adminEventsTopic, key, payload.toString()));
} catch (Exception e) {
log.error("Failed to process admin event for Kafka", e);
}
}
@Override
public void close() {
// Producer is managed by the factory
}
}
Kafka Consumer for Downstream Processing
// kafka-consumer.js - Process Keycloak events from Kafka
const { Kafka } = require('kafkajs');
const kafka = new Kafka({
clientId: 'keycloak-event-processor',
brokers: [process.env.KAFKA_BROKERS || 'localhost:9092']
});
const consumer = kafka.consumer({ groupId: 'event-processors' });
async function startConsumer() {
await consumer.connect();
await consumer.subscribe({
topics: ['keycloak-user-events', 'keycloak-admin-events'],
fromBeginning: false
});
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const event = JSON.parse(message.value.toString());
switch (event.type) {
case 'LOGIN':
await handleLogin(event);
break;
case 'LOGIN_ERROR':
await handleLoginError(event);
break;
case 'REGISTER':
await handleRegistration(event);
break;
case 'UPDATE_PASSWORD':
await handlePasswordChange(event);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
});
}
async function handleLogin(event) {
// Update last login timestamp in your application database
// Send welcome-back notification
// Update analytics
console.log(`User ${event.user_id} logged in from ${event.ip_address}`);
}
async function handleLoginError(event) {
// Increment failed login counter
// Alert on brute force patterns
// Notify security team on suspicious activity
console.log(`Failed login attempt for ${event.details?.username} from ${event.ip_address}`);
}
async function handleRegistration(event) {
// Send welcome email
// Create user in your application database
// Notify sales team of new signup
console.log(`New user registered: ${event.user_id}`);
}
async function handlePasswordChange(event) {
// Send confirmation email
// Log for compliance
console.log(`Password changed for user ${event.user_id}`);
}
startConsumer().catch(console.error);
Event Filtering and Routing Patterns
Not every consumer needs every event. Here are common filtering patterns.
Security Event Stream
Route security-relevant events to your SIEM:
const SECURITY_EVENTS = new Set([
'LOGIN_ERROR',
'UPDATE_PASSWORD',
'SEND_RESET_PASSWORD',
'REMOVE_TOTP',
'UPDATE_TOTP',
'REVOKE_GRANT',
'CUSTOM_REQUIRED_ACTION_ERROR'
]);
function isSecurityEvent(event) {
return SECURITY_EVENTS.has(event.type);
}
For SIEM integration specifically, see our guide on integrating Keycloak logs with Syslog.
User Activity Stream
Route user activity events to your analytics platform:
const ACTIVITY_EVENTS = new Set([
'LOGIN',
'REGISTER',
'LOGOUT',
'UPDATE_PROFILE',
'VERIFY_EMAIL',
'GRANT_CONSENT'
]);
Admin Audit Stream
Route admin events to your compliance system, filtering out noisy operations:
function isAuditableAdminEvent(event) {
// Skip read-only operations
if (event.operation === 'QUERY') return false;
// Skip internal system operations
if (event.auth_details?.client_id === 'admin-cli'
&& event.resource_type === 'REALM') return false;
return true;
}
Skycloak’s Built-In Webhook Support
If you are using Skycloak managed hosting, you do not need to build a custom SPI. Skycloak provides a built-in HTTP webhook event listener that can send events to any endpoint. Configuration is available through the Skycloak dashboard, and the webhook supports HMAC verification, retry logic, and event type filtering out of the box.
For details on setting this up, see our guide on forwarding Keycloak events to SIEM via Skycloak HTTP webhook.
Monitoring Event Pipeline Health
Once you have event streaming running, monitor these metrics:
- Event delivery latency: Time between event generation and delivery to the external system
- Failed delivery count: Number of events that failed all retry attempts
- Queue depth (for Kafka): Consumer lag per partition
- Event volume: Events per second by type (useful for anomaly detection)
Keycloak’s Insights dashboard gives you visibility into event volumes and patterns. For infrastructure-level monitoring, see our security practices page.
Next Steps
Start with the simplest integration that meets your needs. If you just need to send events to a single endpoint, the webhook listener is the right choice. If you need fan-out to multiple consumers, event ordering, or durability guarantees, Kafka is the way to go.
For teams that want event streaming without building and maintaining custom SPIs, Skycloak’s managed Keycloak includes built-in webhook support with a user-friendly configuration interface. Check our pricing to see what is included in each plan.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.