Keycloak Audit with Native Tools: kcadm.sh, Events, and the Admin API
Count the clients in your production realm right now:
/opt/keycloak/bin/kcadm.sh get clients -r production | jq length
If that number is a lot bigger than the number of applications you think you run SSO for, you're normal. Every proof of concept, every contractor integration, every "temporary" test client leaves an entry behind. Each one carries redirect URIs, grant types and possibly a service account that nobody has looked at since the day it was created.
This is the hands-on Keycloak audit: a full sweep of realms, clients, roles, events and authentication flows using only what ships with Keycloak, which is kcadm.sh and the Admin REST API. Nothing to install, nothing third-party. Part one covered which misconfigurations expose SSO and why. This part is the checklist you actually run, and it ends on where that checklist stops.
The audit runs in five sweeps, broad to narrow:
01Sweep 1
Realm settings
TLS, lockout, password policy, registration and lifetimes.
02Sweep 2
Clients
Redirect URIs, password grants, secrets and full scope.
03Sweep 3
Service accounts
Who holds realm-management roles, human or not.
04Sweep 4
Events
Whether logins and admin changes are recorded at all.
05Sweep 5
Auth flows
What a login actually requires, MFA included.
Setup: a read-only audit account for kcadm.sh
kcadm.sh lives in the bin/ directory of any Keycloak distribution (Admin CLI docs). You don't need to run it on the server. Copy the distribution to your workstation and point it at your instance.
Don't audit as a full admin. An audit account with write access is itself a finding. Create a dedicated user in the realm you're auditing and give it only these roles from that realm's realm-management client: view-realm, view-clients, view-users and view-events. Then log in against that realm:
/opt/keycloak/bin/kcadm.sh config credentials \
--server https://sso.example.com \
--realm production \
--user auditor
It prompts for the password and caches a token in ~/.keycloak/kcadm.config. Every command below works with those four roles. If you'd rather keep one auditor in master that can read several realms, grant the same view-* roles from each realm's <realm>-realm client inside master and log in with --realm master.
One more note: modern (Quarkus-based) Keycloak has no /auth path prefix. If your instance still needs --server https://sso.example.com/auth, write that down too. It means you're on a version past end of life.
Sweep 1: realm settings
Start at the realm level, because these defaults apply to every login:
kcadm.sh get realms/production --fields \
sslRequired,bruteForceProtected,failureFactor,permanentLockout,passwordPolicy,\
registrationAllowed,resetPasswordAllowed,rememberMe,\
accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan,offlineSessionIdleTimeout
What each answer should look like:
| Field | Expect | Finding if |
|---|---|---|
sslRequired | external or all | none |
bruteForceProtected | true | false (the default) |
passwordPolicy | a non-empty policy string | null: no length, no history, nothing |
registrationAllowed | false for internal realms | true and you didn't intend self-signup |
accessTokenLifespan | 300 to 900 seconds | hours, since a stolen token lives that long |
ssoSessionIdleTimeout | matches your idle-logout policy | multi-day sessions on a workforce realm |
Repeat for every realm. kcadm.sh get realms --fields realm,enabled gives you the list. Disabled or forgotten realms are still attack surface if their clients hold secrets.
Sweep 2: the client inventory
This is where SSO actually gets exposed, so it gets the deepest pass. Pull everything once, then filter with jq:
kcadm.sh get clients -r production > clients.json
Wildcard and over-broad redirect URIs. A public client with https://app.example.com/* (or worse, a bare *) turns any open redirect into a token-theft primitive:
jq -r '.[] | select((.redirectUris // [])[] | test("\\*"))
| "\(.clientId)\t\(.redirectUris | join(","))"' clients.json | sort -u
Every hit should either become an exact-path URI or come with a written justification. Check webOrigins the same way: + (mirror the redirect URIs) is fine, * is not.
Public clients with password grants. Direct access grants on a public client mean anything holding a username and password can mint tokens with no browser, no consent and no MFA step-up:
jq -r '.[] | select(.publicClient and .directAccessGrantsEnabled) | .clientId' clients.json
Confidential clients and secret hygiene. List confidential clients, then spot-check secrets for the ones that matter:
jq -r '.[] | select(.publicClient | not) | "\(.id)\t\(.clientId)"' clients.json
kcadm.sh get clients/CLIENT_UUID/client-secret -r production
You can't read a secret's age from the API, but you can ask the team when it was last rotated. If the answer is "never" and the client is three years old, that's the finding. Also flag any confidential client whose secret appears in more than one deployment. A shared secret can't be revoked for one consumer without breaking the others.
Full-scope clients. fullScopeAllowed: true (the default) means the client's tokens carry every role the user has, not just what the app needs:
jq -r '[.[] | select(.fullScopeAllowed)] | length' clients.json
If that's nearly all of your clients, the contents of every token are one compromised app away from being a realm-wide role map.
Sweep 3: service accounts and role mappings
Service accounts are where quiet privilege lives. Enumerate them:
jq -r '.[] | select(.serviceAccountsEnabled) | "\(.id)\t\(.clientId)"' clients.json
For each one, resolve the backing user and dump its role mappings:
kcadm.sh get clients/CLIENT_UUID/service-account-user -r production --fields id,username
kcadm.sh get users/SERVICE_ACCOUNT_USER_ID/role-mappings -r production
The line that matters most in that output is anything under the realm-management client, especially realm-admin, manage-users and manage-clients:
kcadm.sh get users/SERVICE_ACCOUNT_USER_ID/role-mappings -r production \
| jq -r '.clientMappings["realm-management"].mappings[]?.name'
A CI pipeline's service account holding manage-users can create an admin. It happens because someone granted realm-admin to unblock an integration and never came back. While you're in role territory, list which humans hold admin too: members of any realm role that composes realm-management roles, and every user in the master realm.
Sweep 4: events, and whether anything is recorded
Keycloak does not persist login or admin events by default (auditing docs). Check:
kcadm.sh get events/config -r production
You want eventsEnabled: true, adminEventsEnabled: true and adminEventsDetailsEnabled: true, with a sensible eventsExpiration. If they're false (the out-of-the-box state), you can't currently answer "who logged in", "who failed to" or "who changed that client". Turning them on is one command. It writes, so run it from an admin session, not your audit account:
kcadm.sh update events/config -r production \
-s eventsEnabled=true -s eventsExpiration=2592000 \
-s adminEventsEnabled=true -s adminEventsDetailsEnabled=true
Once events exist, the audit queries are direct:
# Recent failed logins
kcadm.sh get events -r production -q type=LOGIN_ERROR -q max=50
# Recent admin changes: new clients, role grants, config edits
kcadm.sh get admin-events -r production -q max=50
# Only role grants and removals on client roles (realm-management included)
kcadm.sh get admin-events -r production \
-q resourceTypes=CLIENT_ROLE_MAPPING -q max=50 \
| jq -r '.[] | "\(.time/1000 | todate)\t\(.operationType)\t\(.authDetails.userId)\t\(.authDetails.ipAddress)\t\(.resourcePath)"'
The admin-events query is the closest native Keycloak gets to a change log. The last one is the most useful filter in the whole audit: it lists every role grant with who made it, from which IP and on which user. Read it monthly at minimum.
Sweep 5: authentication flows and required actions
Finally, check what a login actually requires:
kcadm.sh get authentication/flows -r production --fields alias,builtIn,providerId
kcadm.sh get authentication/flows/browser/executions -r production
In the browser flow's executions, find the OTP (or WebAuthn) execution and read its requirement. DISABLED means MFA is off for everyone. CONDITIONAL means it applies only to users who configured it, which without enforcement is usually a minority. Then check required actions:
kcadm.sh get authentication/required-actions -r production --fields alias,name,enabled,defaultAction
CONFIGURE_TOTP enabled but not set as a default action means new users are never pushed into MFA enrollment. VERIFY_EMAIL disabled on a realm with self-registration helps account takeover along. Flag any custom (non-builtIn) flow you can't explain, since copied flows drift away from upstream fixes.
The Admin REST API: the same audit, scriptable
Everything kcadm.sh does is a thin wrapper over the Admin REST API, which matters once you want to schedule this instead of typing it. Create a confidential client in the realm (say audit-cli) with Service accounts enabled and the same four view-* roles. The script should be exactly as unprivileged as the human auditor. Get a token and hit the same endpoints:
TOKEN=$(curl -s "https://sso.example.com/realms/production/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d client_id=audit-cli -d client_secret="$AUDIT_SECRET" | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" \
"https://sso.example.com/admin/realms/production/clients?max=200" \
| jq -r '.[] | select(.publicClient) | .clientId'
A weekly diff in about twenty lines
The most valuable thing to automate is the comparison between this week and last week. Store a trimmed snapshot of every client and diff it:
#!/usr/bin/env bash
set -euo pipefail
REALM=production
TODAY=audit/$(date +%F)
mkdir -p "$TODAY"
kcadm.sh get clients -r "$REALM" \
| jq -S 'map({clientId, publicClient, redirectUris, webOrigins,
directAccessGrantsEnabled, serviceAccountsEnabled, fullScopeAllowed})
| sort_by(.clientId)' > "$TODAY/clients.json"
kcadm.sh get realms/"$REALM" \
--fields bruteForceProtected,passwordPolicy,accessTokenLifespan,ssoSessionMaxLifespan \
> "$TODAY/realm.json"
PREV=$(ls -d audit/*/ | sort | tail -n 2 | head -n 1)
if [ "${PREV%/}" != "$TODAY" ]; then
diff -u "$PREV/clients.json" "$TODAY/clients.json" || true
diff -u "$PREV/realm.json" "$TODAY/realm.json" || true
fi
Run it from cron with the audit account, post the diff to a channel, and you'll see new clients, widened redirect URIs and loosened realm settings within a week of them happening. It's an afternoon of work and worth doing.
What each native tool answers
| Tool | Answers | Doesn't answer |
|---|---|---|
kcadm.sh get | What the realm looks like right now | When it changed, or why |
| Login events | Who logged in, who failed, from where | Whether the failures are an attack or a typo |
| Admin events | Who changed what, from which IP | Whether that change was approved or safe |
| Admin REST API | Everything above, scriptable | Anything about intent |
| Weekly diff | What's new since last week | Whether "new" is a problem |
Read the right-hand column top to bottom and you get the honest ceiling of the native toolchain.
Where the DIY audit stops
Run this audit. It will find things. But know what it is: a photograph of the realm on audit day.
01Cadence
A snapshot
The sweep describes audit day. The next client launch starts aging it.
02Cost
Hours per pass
Commands are quick. Asking who owns each client and secret is not.
03Judgment
Diffs, not verdicts
A cron diff says a client changed. It cannot say whether the change is safe.
04Action
Tickets
Nothing here tightens a URI or revokes a role. Every finding needs an owner.
A realm drifts every time someone ships a client. Each integration adds redirect URIs, maybe a service account, maybe a role grant. Your findings document starts aging the next sprint.
The hours are real. A first pass takes 2 to 4 hours, and the follow-up conversations ("who owns this client?", "when did we rotate this secret?") take longer than the commands. Quarterly intentions become yearly reality.
A diff catches drift, not judgment. It tells you a client appeared. It doesn't tell you whether https://staging-tools.example.com/* on that client is fine or a hole, or whether the engineer who granted manage-users yesterday had a reason.
Detection isn't remediation. Nothing above tightens a URI, revokes a role or rotates a secret. Every finding is a ticket, and tickets without an owner are where audit findings go to die.
The work that's left after the native tools is investigation: reading a change in context (who made it, what else they changed, whether the new permission is ever used) and deciding what to do about it. That's the part part three hands to an agent.
Related reading
- Part one: 7 Keycloak misconfigurations that quietly expose your SSO
- Part three: automating your Keycloak audit with an AI security agent
- How to audit and monitor HashiCorp Vault with native tools
- What is non-human identity?
Every sweep in this guide can run continuously instead of quarterly. CloudThinker connects to Keycloak with a read-only service account, watches realms, clients, roles and events every day, flags the changes that widen access, and changes nothing without your approval. Read part three, or see your own realm's findings first: try CloudThinker free with 100 premium credits and no card required.
