Last updated: July 2026
Handling GDPR in Keycloak comes down to four things: knowing what PII Keycloak stores (profile attributes, credentials, sessions, and crucially the event/audit logs), being able to fully delete a user via the Admin API, anonymizing instead of deleting where you must keep a record, and automating retention so inactive accounts and stale events are purged on a schedule. Critically, deleting a user does NOT remove their entries from the events tables — those must be handled separately, or you will have orphaned PII sitting in your database long after the erasure request was fulfilled.
This guide is aimed at Keycloak operators and compliance engineers who need to demonstrate a working, auditable GDPR process — not just check a box. It covers the full PII inventory, the Admin API calls required for erasure, an anonymization pattern for legal holds, a bulk-purge script for inactive accounts, and how to configure event retention so your audit tables do not become a liability.
What PII Does Keycloak Store?
Before you can honor an erasure or data portability request under Article 17 of the GDPR, you need an accurate map of where personal data lives inside Keycloak. The answer is broader than most teams expect.
User Profile Attributes
Every Keycloak user record in the USER_ENTITY table holds:
email— typically the primary identifier and unambiguously personal datausername— often an email address or real namefirstNameandlastNamecreatedTimestampandenabledflag- Any custom attributes stored in
USER_ATTRIBUTE(phone numbers, employee IDs, department, address fields, or any claim your application maps into the token)
Custom attributes deserve special attention. If your registration flow or SCIM sync pushes additional fields into Keycloak user profiles — see our guide on using SCIM 2.0 with Skycloak for how that works — those fields are stored in USER_ATTRIBUTE rows keyed by user ID. They are deleted when the parent user record is deleted, but only if your application did not replicate them elsewhere.
Credentials and Hashes
Credential data lives in CREDENTIAL and associated tables. This includes:
- Bcrypt or Argon2 password hashes (not reversible, but still personal data under GDPR since they are linked to an identity)
- OTP seed secrets for TOTP authenticators
- WebAuthn credential blobs
Deleting the user record cascades to credential rows via the foreign key relationship in the default Keycloak schema.
Federated Identities
If users log in via a social login provider or an enterprise IdP, Keycloak stores a FEDERATED_IDENTITY record linking the Keycloak user ID to the external subject identifier (sub claim). This is PII because the external sub, combined with the provider name, can re-identify the individual. These rows are cascade-deleted with the user.
Sessions and Tokens
Active and offline sessions — stored in the USER_SESSION and OFFLINE_USER_SESSION tables (or in an external Infinispan cluster for distributed deployments) — contain the user ID, IP address, and device/browser metadata. Sessions are deleted when the user is deleted, but offline sessions may persist in the Infinispan distributed cache until they expire if Keycloak is running in distributed mode without a persistent store that respects the delete operation.
Consents
OAuth consent records in USER_CONSENT track which clients a user authorized and which scopes they approved. These are cascade-deleted with the user.
Login Events and Admin Events
This is the category that most operators miss. Keycloak’s event subsystem writes records to two database tables:
EVENT_ENTITY— login events (LOGIN, LOGIN_ERROR, LOGOUT, REGISTER, RESET_PASSWORD, etc.) containing user ID, IP address, client ID, timestamp, and session IDADMIN_EVENT_ENTITY— admin operations (user created, role assigned, etc.) containing the auth subject ID and a JSON representation of the resource
Deleting a user does not delete their rows in EVENT_ENTITY or ADMIN_EVENT_ENTITY. The foreign key from event rows to the user table is not enforced with a cascading delete constraint in the default schema. After a hard delete, you are left with event rows containing a now-orphaned user ID — which is still personal data under GDPR because it was associated with a real person at the time of recording. We cover event retention configuration in detail in the Keycloak Auditing and Event Logging Complete Guide.
Right to Erasure: Deleting a User via the Admin API
Under GDPR Article 17, a data subject can request that you erase all personal data you hold about them. For Keycloak, a full erasure requires two steps: delete the user record, then delete the associated event log entries.
Step 1: Look Up the User ID
First, find the user’s internal ID from their email address:
curl -s
-H "Authorization: Bearer $ACCESS_TOKEN"
"https://your-keycloak.example.com/admin/realms/{realm}/[email protected]&exact=true"
| jq '.[0].id'
Replace {realm} with your realm name. The exact=true parameter prevents partial matches on the email field. Store the returned UUID — you will need it for subsequent calls.
Step 2: Delete the User
curl -s -X DELETE
-H "Authorization: Bearer $ACCESS_TOKEN"
"https://your-keycloak.example.com/admin/realms/{realm}/users/{userId}"
A 204 No Content response means the user record, their credentials, federated identities, consents, and active sessions have been removed. Per the Keycloak Admin REST API documentation, this is a hard delete — there is no soft-delete or recycle bin.
Step 3: Purge Event Log Entries for the User
Because event rows are not cascade-deleted, you must remove them separately. Keycloak does not expose a “delete events by user” API endpoint as of version 26.x. Your options are:
Option A — Direct SQL (most thorough):
-- Remove login events for the deleted user
DELETE FROM EVENT_ENTITY
WHERE USER_ID = 'the-user-uuid-here'
AND REALM_ID = 'your-realm-name';
-- Admin events reference an auth.userId in the AUTH_DETAILS column (JSON).
-- These are harder to target without parsing JSON — use expiration-based retention instead.
DELETE FROM ADMIN_EVENT_ENTITY
WHERE REALM_ID = 'your-realm-name'
AND AUTH_USER_ID = 'the-user-uuid-here';
Run this through a controlled migration script, not ad hoc in production. Wrap it in a transaction and log the affected row counts for your erasure audit trail.
Option B — Rely on event expiration: If you have configured event expiration (covered below), events will age out within your defined window. Document this in your privacy notice so data subjects understand the expected timeline.
For user-initiated account deletion — where the end user requests erasure from within your application — see the companion post on Keycloak self-service delete account which covers the required action and email confirmation flow.
Anonymization: When You Cannot Hard-Delete
There are situations where a hard delete is legally problematic. If your application has transaction records, billing history, or audit obligations that reference the Keycloak user ID, deleting the user record may break referential integrity or create compliance gaps in your own application database.
In these cases, anonymization is a recognized GDPR alternative to erasure. The principle is that if personal data is irreversibly de-identified such that the individual can no longer be re-identified, it falls outside GDPR’s scope. Pseudonymization (replacing identifiers with tokens that could theoretically be reversed) does not qualify — full anonymization does.
Anonymization Pattern in Keycloak
The approach is to overwrite all identifying attributes with non-identifiable values, disable the account, and remove credentials:
USER_ID="the-user-uuid-here"
ANON_SUFFIX=$(openssl rand -hex 8)
REALM="your-realm"
BASE_URL="https://your-keycloak.example.com"
# Overwrite the user profile with anonymized values
curl -s -X PUT
-H "Authorization: Bearer $ACCESS_TOKEN"
-H "Content-Type: application/json"
-d "{
"email": "deleted-${ANON_SUFFIX}@anonymized.invalid",
"username": "deleted-${ANON_SUFFIX}",
"firstName": "Deleted",
"lastName": "User",
"enabled": false,
"attributes": {}
}"
"${BASE_URL}/admin/realms/${REALM}/users/${USER_ID}"
# Remove all credentials (password, OTP, WebAuthn)
curl -s
-H "Authorization: Bearer $ACCESS_TOKEN"
"${BASE_URL}/admin/realms/${REALM}/users/${USER_ID}/credentials"
| jq -r '.[].id'
| while read CRED_ID; do
curl -s -X DELETE
-H "Authorization: Bearer $ACCESS_TOKEN"
"${BASE_URL}/admin/realms/${REALM}/users/${USER_ID}/credentials/${CRED_ID}"
done
# Remove federated identities
curl -s
-H "Authorization: Bearer $ACCESS_TOKEN"
"${BASE_URL}/admin/realms/${REALM}/users/${USER_ID}/federated-identity"
| jq -r '.[].identityProvider'
| while read PROVIDER; do
curl -s -X DELETE
-H "Authorization: Bearer $ACCESS_TOKEN"
"${BASE_URL}/admin/realms/${REALM}/users/${USER_ID}/federated-identity/${PROVIDER}"
done
After this script runs, the user record still exists in Keycloak (preserving any referential integrity your application needs), but no personally identifiable information remains. The @anonymized.invalid domain is an RFC 2606 reserved domain that will never resolve — it signals to downstream systems that the address is a placeholder.
Document the anonymization timestamp in a custom attribute (anonymizedAt) for your audit trail, and ensure your token issuance is disabled by the enabled: false flag.
Bulk-Purging Inactive Users
Retention is not just about responding to erasure requests — it means proactively deleting accounts that have been inactive beyond your defined retention window. Most GDPR data minimization obligations (Article 5(1)(e)) require that you do not hold personal data longer than necessary.
Keycloak does not have a native scheduled purge job for inactive users. You build it against the Admin REST API.
Pagination-Aware Purge Script
The Admin API returns users in pages of up to 100. For a realm with thousands of users, you must paginate. Here is a Bash sketch that iterates all users, checks last-login age, and deletes those inactive beyond a threshold:
#!/usr/bin/env bash
# purge-inactive-users.sh
# Deletes Keycloak users whose lastLogin event is older than RETENTION_DAYS
# Requires: curl, jq, access token with realm-management/manage-users role
BASE_URL="https://your-keycloak.example.com"
REALM="your-realm"
ACCESS_TOKEN="${KC_ACCESS_TOKEN}"
RETENTION_DAYS=365
DRY_RUN=true # Set to false to perform actual deletions
PAGE_SIZE=100
OFFSET=0
CUTOFF_EPOCH=$(( $(date +%s) - RETENTION_DAYS * 86400 ))
echo "Purge run: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Cutoff: $(date -u -d "@${CUTOFF_EPOCH}" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "${CUTOFF_EPOCH}" +%Y-%m-%dT%H:%M:%SZ)"
echo "Dry run: ${DRY_RUN}"
echo "---"
DELETED=0
SKIPPED=0
ERRORS=0
while true; do
USERS=$(curl -s
-H "Authorization: Bearer ${ACCESS_TOKEN}"
"${BASE_URL}/admin/realms/${REALM}/users?first=${OFFSET}&max=${PAGE_SIZE}&briefRepresentation=false")
COUNT=$(echo "$USERS" | jq 'length')
[ "$COUNT" -eq 0 ] && break
echo "$USERS" | jq -c '.[]' | while read -r USER; do
USER_ID=$(echo "$USER" | jq -r '.id')
USERNAME=$(echo "$USER" | jq -r '.username')
# Retrieve last login event for this user
LAST_LOGIN_MS=$(curl -s
-H "Authorization: Bearer ${ACCESS_TOKEN}"
"${BASE_URL}/admin/realms/${REALM}/events?user=${USER_ID}&type=LOGIN&max=1"
| jq '.[0].time // 0')
LAST_LOGIN_EPOCH=$(( LAST_LOGIN_MS / 1000 ))
if [ "$LAST_LOGIN_EPOCH" -lt "$CUTOFF_EPOCH" ]; then
if [ "$DRY_RUN" = "true" ]; then
echo "[DRY RUN] Would delete: ${USERNAME} (${USER_ID}), last login: ${LAST_LOGIN_EPOCH}"
else
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE
-H "Authorization: Bearer ${ACCESS_TOKEN}"
"${BASE_URL}/admin/realms/${REALM}/users/${USER_ID}")
if [ "$STATUS" = "204" ]; then
echo "[DELETED] ${USERNAME} (${USER_ID})"
DELETED=$((DELETED + 1))
else
echo "[ERROR] HTTP ${STATUS} deleting ${USERNAME} (${USER_ID})"
ERRORS=$((ERRORS + 1))
fi
fi
else
SKIPPED=$((SKIPPED + 1))
fi
done
OFFSET=$((OFFSET + PAGE_SIZE))
done
echo "---"
echo "Deleted: ${DELETED} | Skipped: ${SKIPPED} | Errors: ${ERRORS}"
A few production considerations: add a short delay between delete calls to avoid pressuring the database; filter out service accounts and machine identities using a custom attribute like retentionExempt=true; and write the list of deleted user IDs to an external log before executing deletions — you cannot retrieve a user from Keycloak once it is gone. Note that the lastLogin attribute in the user representation is only reliably populated if you have configured it; the event-based query in the script is more accurate but depends on event retention not having already expired those records.
For the full pagination pattern and token refresh handling in longer-running scripts, see the Keycloak Admin API bulk pagination guide.
Event and Log Retention Configuration
Keycloak stores events in the database only if you have configured it to do so. Two separate settings control this, both under Realm Settings > Events in the Admin Console:
- Save Events (login events): Toggle on, then set Expiration in seconds. For example,
7776000= 90 days. - Save Admin Events (admin events): Separate toggle, separate expiration setting.
Without an expiration value, events accumulate indefinitely. On a busy realm processing thousands of logins per day, EVENT_ENTITY can grow to millions of rows within months, impacting query performance. This is a known operational problem covered in the Keycloak database tuning and PostgreSQL optimization guide.
Keycloak expires events lazily: it does not run a scheduled cleanup job. Events are deleted when new events for the same realm are written and the expiration check triggers. In practice, this means a quiet realm may retain expired events until the next login occurs. For deterministic cleanup, run a database-level cron:
-- PostgreSQL: delete expired login events
DELETE FROM EVENT_ENTITY
WHERE REALM_ID = 'your-realm'
AND (EXPIRATION IS NOT NULL AND EXPIRATION < EXTRACT(EPOCH FROM NOW()) * 1000);
-- PostgreSQL: delete expired admin events
DELETE FROM ADMIN_EVENT_ENTITY
WHERE REALM_ID = 'your-realm'
AND (EXPIRATION IS NOT NULL AND EXPIRATION < EXTRACT(EPOCH FROM NOW()) * 1000);
Run this as a scheduled job (cron or pgAgent) weekly or monthly, and log row counts for your data processing records.
What to Set as Your Retention Window
Your retention window must satisfy two competing constraints:
- Long enough for security incident investigation (most security teams want 90 days minimum; some compliance frameworks require 12 months)
- Short enough to limit GDPR exposure (the longer you hold event data tied to user IDs, the larger your PII footprint)
A common approach: retain login events for 90 days in Keycloak’s database, forward all events in real time to your SIEM (Splunk, Elastic, Datadog) where access is controlled and retention can be longer and policy-controlled. The SIEM becomes the long-term audit record; Keycloak’s database becomes a short-term operational store. See forwarding Keycloak events to SIEM via Skycloak HTTP webhook for the implementation.
Data Residency and Encryption
GDPR does not mandate encryption at rest, but encryption is a recognized technical measure under Article 32 (security of processing), and it substantially reduces the impact of a database breach.
Keycloak itself does not encrypt the database — it delegates that to the infrastructure layer. For PostgreSQL deployments:
- Enable Transparent Data Encryption (TDE) at the storage level (available in PostgreSQL 17+ natively, or via pgcrypto for column-level encryption on earlier versions)
- Encrypt credentials in transit: ensure Keycloak connects to PostgreSQL over TLS (
ssl=truein the JDBC URL) - For cloud deployments, use provider-managed encryption (AWS RDS encryption, Google Cloud SQL encryption) so the keys are managed outside the database server
Data residency requirements (GDPR Article 46, Schrems II implications) affect where you run Keycloak. If your users are in the EU, the Keycloak instance and its PostgreSQL database must reside in EU data centers unless you have appropriate transfer mechanisms in place. Managed Keycloak providers like Skycloak offer EU-region deployments with data residency guarantees — see the Skycloak pricing and deployment options for region availability.
Compliance Checklist
Use this as a reference when preparing for a GDPR audit or a DPA (Data Protection Authority) inquiry:
| Obligation | GDPR Article | Keycloak Implementation |
|---|---|---|
| Respond to erasure requests within 30 days | Art. 17 | Admin API DELETE /users/{id} + SQL event purge |
| Maintain records of processing activities | Art. 30 | Document realm configuration, event retention settings |
| Data minimization — collect only necessary attributes | Art. 5(1)(c) | Audit custom attributes; remove unused profile fields |
| Storage limitation — do not hold data longer than needed | Art. 5(1)(e) | Configure event expiration; run inactive user purge |
| Security of processing — appropriate technical measures | Art. 32 | TLS in transit, DB encryption at rest, credential hashing |
| Notify supervisory authority of breach within 72 hours | Art. 33 | Event logs + SIEM alerts are evidence for breach scope |
| Respond to access requests (data portability) | Art. 15, 20 | Admin API GET /users/{id} + /users/{id}/credentials inventory |
| Implement anonymization where erasure is not possible | Art. 4(1) definition | Overwrite + disable pattern (see above) |
For the identity governance controls that feed into this checklist — provisioning, deprovisioning, and role lifecycle — see the identity governance workflows guide.
Frequently Asked Questions
Is Keycloak GDPR compliant?
Keycloak is a tool, not a compliance certification. It does not carry a GDPR certification because GDPR compliance is an organizational obligation, not a product attribute. What matters is how you configure and operate Keycloak: whether you configure appropriate event retention, whether you can respond to erasure and access requests via the Admin API, and whether the infrastructure hosting Keycloak meets the security and data residency requirements of Article 32 and Article 46. A well-configured Keycloak deployment on compliant infrastructure can fully support GDPR obligations.
Does deleting a Keycloak user delete their event logs?
No. Deleting a user via DELETE /admin/realms/{realm}/users/{userId} removes the user record, credentials, sessions, federated identities, and consents — but it does not delete rows in EVENT_ENTITY or ADMIN_EVENT_ENTITY. Those tables contain the user ID as a plain column with no cascading delete constraint in the default Keycloak schema. You must explicitly delete event rows by user ID via direct SQL, or rely on the realm-level event expiration setting to age them out. If you are responding to a formal erasure request, direct SQL deletion is the only way to confirm immediate removal.
How do I purge inactive users in Keycloak?
Keycloak has no native scheduled purge job. The standard approach is to script against the Admin REST API: paginate through all users (using the first and max query parameters), retrieve each user’s last LOGIN event from the events API or check the lastLogin attribute if stored, compare against your retention cutoff, and issue a DELETE for accounts that exceed it. Run the script as a scheduled job (cron, CI pipeline, or n8n workflow) on a monthly or quarterly cadence. Always perform a dry run first and log the deleted user IDs to an external audit file before executing deletions.
What is the difference between anonymization and pseudonymization under GDPR?
Pseudonymization replaces direct identifiers (like email) with a token that can be reversed if the mapping table is available. GDPR considers pseudonymized data to still be personal data (Recital 26). Anonymization irreversibly removes all identifying information such that re-identification is not reasonably possible. Only anonymized data falls outside GDPR’s scope. In the Keycloak context, the anonymization pattern in this guide (overwriting with random values + deleting credentials) qualifies as anonymization if you also ensure the original values are not retained in application logs, backups, or your SIEM.
Do Keycloak session tokens constitute personal data?
Yes. A session token is tied to a specific user identity. Even if the token itself is a random UUID, the session record in Keycloak’s database links that UUID to a user ID, IP address, and browser fingerprint. JWT access tokens issued by Keycloak contain the sub claim (user ID) and often the email address, making them personal data in their own right. Access tokens expire naturally, but refresh tokens and offline tokens can persist. Ensure offline token expiration is configured (Realm Settings > Sessions > Offline Session Idle and Offline Session Max) so stale offline tokens do not create indefinite personal data retention.
GDPR compliance for Keycloak is an operational discipline, not a one-time configuration. The Admin API gives you the tools to respond to individual erasure requests, but scale requires automation: scheduled event expiration, regular inactive-user purge jobs, and a clear data map that your DPO can point to when a supervisory authority comes asking.
If you would rather focus on your application and hand off the infrastructure compliance work — database encryption, EU-region hosting, backup policies, and managed retention tooling — Skycloak’s managed Keycloak platform handles the operational layer so your team can stay focused on building.