Keycloak Audit Logging for User Events and Admin Actions
Build auditable Keycloak event logging so authentication and administrative identity actions remain searchable, attributable, and reviewable.
Published · Republished on Medium

Why identity events need an external audit path
Keycloak can persist user and administrator events when each realm is configured to do so, and administrators can search retained events through the Admin Console. Keycloak can also send events to configured listeners such as its logging listener.
Those capabilities still need an operating design. Realm configuration can drift, database retention is finite, ordinary application logs can be rotated or altered, and raw identifiers may be difficult to investigate. A custom Event Listener SPI can emit a stable JSON envelope to the server logging pipeline, where a separate collector sends it to protected storage.
The listener is one evidence source, not a guarantee of a complete audit trail. It records only events Keycloak publishes to that SPI and only while the provider and logging path function correctly.
Scope and version contract
The original implementation was built against Keycloak 26.5. This revision uses Keycloak 26.7.0 as the current compilation example. A provider compiled against one Keycloak release must not be assumed compatible with every 24.x–26.x release.
Keycloak extensions share the server classpath, and private SPI contracts can change. Build and test a provider artifact for each supported Keycloak release, pin the server image and provider checksum, and run the authentication/admin regression suite before upgrading.
Events this listener covers
EventListenerProvider receives:
- User events such as login, login errors, logout, registration, password actions, token operations, and other event types enabled by the realm.
- Administrator events for operations Keycloak publishes through the admin-event path.
It does not automatically capture:
- Database changes made outside Keycloak.
- Reverse-proxy, network, operating-system, or container activity.
- Every custom provider's internal behavior unless that provider emits a Keycloak event.
- A log event that was dropped because the process, disk, queue, or collector failed.
- Semantic before/after state unless explicitly and safely recorded.
Maintain a control matrix mapping required identity actions to observed event types and test it after every upgrade.
Audit schema
Use a versioned envelope so parsers can evolve safely:
json
{
"@timestamp": "2026-08-11T08:30:00Z",
"audit_version": 2,
"kind": "admin_event",
"service": "keycloak",
"realm_id": "7f...",
"realm_name": "project-a",
"operation_type": "CREATE",
"resource_type": "USER",
"resource_path": "users/opaque-id",
"actor_user_id": "opaque-id",
"actor_user_name": "admin-project-a",
"actor_client_id": "security-admin-console",
"source_ip": "192.0.2.10",
"outcome": "success",
"enrichment_status": "complete"
}Keep immutable identifiers alongside mutable names. A username can change or be reused; recording only the enriched name weakens attribution.
Use the event's timestamp when available rather than only Instant.now(). Collector receipt time can be recorded separately to detect delivery delay and clock problems.
Sensitive-data policy
Audit data can contain personal data and secrets. Keycloak event details and administrator representations may include usernames, email addresses, IPs, redirect URIs, session identifiers, authorization codes, client configuration, or credential-related fields.
Use an allowlist, not a denylist:
- Include only fields required by an approved detection, investigation, or control.
- Do not log access tokens, refresh tokens, authorization codes, passwords, secrets, cookies, or credential representations.
- Keep
includeRepresentationdisabled by default. An admin representation is not safe merely because it is JSON. - Hashing a username is pseudonymization, not anonymization, if the value can be linked back to a person.
- Restrict audit-store access, encrypt transport/storage, and apply a documented retention and deletion policy.
The sample logs in this article use documentation addresses and opaque identifiers. Do not publish real production identity logs in screenshots or examples.
Provider implementation pattern
The listener should do minimal synchronous work. Keycloak invokes it in the request path, so blocking network calls or expensive database lookups can increase authentication/admin latency.
java
package com.acme.keycloak.audit;
import org.jboss.logging.Logger;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.AuthDetails;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
public final class AuditEventListenerProvider implements EventListenerProvider {
private static final Logger LOG = Logger.getLogger("com.acme.keycloak.audit");
private final KeycloakSession session;
public AuditEventListenerProvider(KeycloakSession session) {
this.session = session;
}
@Override
public void onEvent(Event event) {
Map<String, Object> out = envelope("user_event", event.getTime());
out.put("event_type", name(event.getType()));
out.put("realm_id", event.getRealmId());
out.put("user_id", event.getUserId());
out.put("client_id", event.getClientId());
out.put("source_ip", event.getIpAddress());
out.put("outcome", event.getError() == null ? "success" : "failure");
out.put("error", event.getError());
enrichRealm(out, event.getRealmId());
enrichUser(out, event.getRealmId(), event.getUserId(), "user_name");
emit(out);
}
@Override
public void onEvent(AdminEvent event, boolean includeRepresentation) {
Map<String, Object> out = envelope("admin_event", event.getTime());
out.put("operation_type", name(event.getOperationType()));
out.put("resource_type", name(event.getResourceType()));
out.put("resource_path", event.getResourcePath());
out.put("realm_id", event.getRealmId());
out.put("outcome", event.getError() == null ? "success" : "failure");
out.put("error", event.getError());
AuthDetails auth = event.getAuthDetails();
if (auth != null) {
out.put("actor_user_id", auth.getUserId());
out.put("actor_client_id", auth.getClientId());
out.put("source_ip", auth.getIpAddress());
enrichUser(out, event.getRealmId(), auth.getUserId(), "actor_user_name");
}
// Intentionally do not copy event.getRepresentation().
enrichRealm(out, event.getRealmId());
emit(out);
}
private Map<String, Object> envelope(String kind, long eventTime) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("@timestamp", Instant.ofEpochMilli(eventTime).toString());
out.put("audit_version", 2);
out.put("kind", kind);
out.put("service", "keycloak");
out.put("enrichment_status", "not_attempted");
return out;
}
private void enrichRealm(Map<String, Object> out, String realmId) {
if (realmId == null) return;
try {
RealmModel realm = session.realms().getRealm(realmId);
if (realm != null) out.put("realm_name", realm.getName());
out.put("enrichment_status", "complete");
} catch (RuntimeException e) {
out.put("enrichment_status", "failed");
out.put("enrichment_error", e.getClass().getSimpleName());
}
}
private void enrichUser(
Map<String, Object> out,
String realmId,
String userId,
String field) {
if (realmId == null || userId == null) return;
try {
RealmModel realm = session.realms().getRealm(realmId);
if (realm == null) return;
UserModel user = session.users().getUserById(realm, userId);
if (user != null) out.put(field, user.getUsername());
} catch (RuntimeException e) {
out.put("enrichment_status", "partial");
out.put("enrichment_error", e.getClass().getSimpleName());
}
}
private String name(Enum<?> value) {
return value == null ? null : value.name();
}
private void emit(Map<String, Object> event) {
LOG.info(AuditJson.write(event));
}
@Override
public void close() {
}
}AuditJson.write represents a tested JSON serializer with bounded output. Do not build JSON by string concatenation. Test control-character escaping, Unicode, maximum field length, nulls, nested data, and log-injection inputs. A mature JSON logging library is preferable when its dependencies are compatible with Keycloak's shared classpath.
The sample deliberately omits details, session_id, and representation. Add individual allowlisted fields only after reviewing their data classification.
Factory and SPI registration
java
package com.acme.keycloak.audit;
import org.keycloak.Config;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public final class AuditEventListenerProviderFactory
implements EventListenerProviderFactory {
public static final String ID = "audit-log";
@Override
public EventListenerProvider create(KeycloakSession session) {
return new AuditEventListenerProvider(session);
}
@Override public void init(Config.Scope config) { }
@Override public void postInit(KeycloakSessionFactory factory) { }
@Override public void close() { }
@Override public String getId() { return ID; }
}Create this service registration file:
text
src/main/resources/META-INF/services/org.keycloak.events.EventListenerProviderFactoryIts content is:
text
com.acme.keycloak.audit.AuditEventListenerProviderFactoryBuild against the exact Keycloak release
Use Keycloak's dependency management and the smallest set of provided dependencies required by the implementation:
xml
<properties>
<maven.compiler.release>21</maven.compiler.release>
<keycloak.version>26.7.0</keycloak.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-parent</artifactId>
<version>${keycloak.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>If compilation requires a private artifact, document that compatibility risk instead of claiming broad version support. Pin Maven plugins and verify dependencies in the actual provider repository.
Build an optimized server image
dockerfile
ARG KEYCLOAK_VERSION=26.7.0
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} AS builder
COPY --chown=keycloak:keycloak \
target/keycloak-audit-listener-2.0.0.jar \
/opt/keycloak/providers/keycloak-audit-listener.jar
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized"]Pin the base image by digest after testing. Scan both the provider and final image, generate an SBOM, and record the JAR checksum. Provider JARs are not isolated from Keycloak's classpath, so shading conflicting libraries can break the server.
Do not use a public, prebuilt custom Keycloak image without reviewing its source, provenance, provider artifact, and base digest. Do not deploy default administrator credentials, non-strict hostname settings, or development HTTP settings from a demo command into production.
Enable and verify per realm
In each intended realm, enable the event types and add audit-log under Realm settings → Events → Event listeners. Keep the existing listener required by your operating model; event-listener SPIs support multiple implementations.
Configuration drift checks should verify:
- user-event persistence and retention settings where required;
- admin-event persistence and whether representation storage is disabled;
- the configured listener list for every realm;
- provider presence and version on every Keycloak pod;
- the downstream log collector and audit-store ingestion state.
A listener being listed in the Admin Console does not prove delivery to the final store.
Delivery architecture and failure policy
Prefer writing one bounded event to the local logging subsystem and shipping it with a node/sidecar/daemon collector. Avoid synchronous calls from the listener to Elasticsearch, Kafka, or a remote SIEM; downstream latency could affect authentication.
Define what happens when:
- local stdout/file writes block or fail;
- the collector is unavailable;
- the node disk fills;
- the remote store rejects or throttles events;
- enrichment database lookups fail;
- an event is larger than the collector limit;
- duplicate delivery occurs after retry;
- clocks differ between nodes.
Use buffering with bounded disk/memory, high-watermark alerts, retry limits, dead-letter handling where appropriate, and a unique event fingerprint for deduplication. Decide whether identity availability or audit completeness has priority for each failure; neither silent loss nor indefinite blocking is acceptable.
Integrity, access, and retention
Forward audit events to a separate security boundary with encrypted transport, append-oriented permissions, restricted deletion, time synchronization, and monitored retention. Where controls require it, use immutable/WORM-capable storage and periodically verify exported hashes or signatures.
Keycloak administrators should not automatically have permission to delete external audit evidence. Conversely, audit analysts should not receive Keycloak administrative access merely because they can read logs.
Retention must balance investigation/control requirements with data minimization and privacy obligations. Document deletion holds, subject-access handling, and who can view identity activity.
Upgrade and regression tests
For every Keycloak or provider release, test at least:
- Successful and failed login, logout, password actions, and token-related events required by policy.
- User create/update/delete, role/group mapping, client change, and credential reset admin operations.
- Actor and target IDs remain correct when usernames change or users are deleted.
- Representations, secrets, codes, tokens, cookies, and passwords never appear.
- Control characters and very large fields remain one valid bounded JSON event.
- Enrichment failure still emits the base event.
- Collector outage, backpressure, disk pressure, retry, and duplicate delivery produce alerts.
- Multi-node rollout emits the expected provider/schema version from every pod.
- Disabling the listener or changing realm event settings is detected.
Compare expected fixtures with events in the final audit store, not only container stdout.
Rollout and rollback
Deploy the provider to a non-production realm, validate the schema and data classification, then canary one Keycloak pod or environment. Keep parsers backward-compatible while both schema versions can coexist during rollout.
Rollback requires the previous Keycloak image, provider JAR, configuration, and parser support. Removing a provider changes only future capture; it does not repair already lost events. Preserve evidence and record the gap window during any failure or rollback.
Evidence-qualified results
The original implementation produced useful single-line events for tested login, logout, failed-login, user, client, role, and mapping actions. It helped investigate misconfiguration and administrative changes. Those observations do not establish that every Keycloak action, version, realm, or failure condition is covered.
Report results with the tested Keycloak/provider versions, realm configuration, event corpus, delivery path, and observation period. “Works for months” is operational context, not a compatibility guarantee.
Official references
- Keycloak Server Developer Guide: Event Listener SPI
- Keycloak Server Developer Guide: registering providers
- Keycloak provider configuration
- Keycloak Server Administration Guide
- Keycloak logging configuration
- Keycloak upgrading guide
Conclusion
A Keycloak audit listener is valuable when it emits a small, versioned, allowlisted event and relies on a resilient external logging path. Keep immutable IDs, minimize personal data, avoid synchronous remote sinks, test every required action and failure condition, and rebuild the provider against each supported Keycloak release.