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 meaningfully 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 — and 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 — kcadm.sh and the Admin REST API. Nothing to install, nothing third-party. In part one we covered which misconfigurations expose SSO and why; this article is the checklist you actually run. Budget 2–4 hours for a first pass on a realm with 30–80 clients.
Setup: authenticate 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:
/opt/keycloak/bin/kcadm.sh config credentials \
--server https://sso.example.com \
--realm master \
--user auditor
It prompts for the password and caches a token in ~/.keycloak/kcadm.config. Two notes before you start:
- Don't audit as full admin. Create a dedicated account and grant it only the
view-realm,view-clients,view-users, andview-eventsroles from the target realm'srealm-managementclient. Every command below works read-only with those roles — and an audit account with write access is itself a finding. - Modern (Quarkus-based) Keycloak has no
/authpath prefix. If your instance is old enough to need--server https://sso.example.com/auth, note that 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–900 seconds | hours; 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 redirect URIs) is acceptable; * is not.
Public clients with password grants. Direct access grants on a public client means anything holding a username and password can mint tokens with no browser, no consent, 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 — shared secrets 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, token contents 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 single most important line to grep for 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, also list which humans hold admin: members of any realm role composing realm-management roles, and every user in the master realm.
Sweep 4: events — is anything being 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 sane eventsExpiration. If they're false — which is the out-of-the-box state — you currently cannot answer "who logged in", "who failed to", or "who changed that client". Enabling is one command (this one 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
That second command is the closest native Keycloak gets to a change log. Read it monthly at minimum.
Sweep 5: authentication flows and required actions
Finally, verify 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 is an account-takeover assist. Flag any custom (non-builtIn) flow you can't explain — copied flows drift 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 when you want to schedule this instead of typing it. Get a token, then hit the same endpoints:
TOKEN=$(curl -s "https://sso.example.com/realms/master/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'
Use a confidential client with a service account carrying only the view-* roles from Sweep 1's advice — the audit script should be exactly as unprivileged as the human auditor. From here, wrapping the jq filters above into a cron job that diffs clients.json week over week is an afternoon of work, and genuinely worth doing.
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.
A realm drifts every time someone ships a new 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. First pass 2–4 hours, and the follow-up interviews ("who owns this client?", "when did we rotate this secret?") take longer than the commands. Quarterly intentions become annual reality.
Cron catches drift, not judgment. A diff tells you a client appeared; it doesn't tell you whether https://staging-tools.example.com/* on that client is fine or a hole.
Detection isn't remediation. Nothing above tightens a URI, revokes a role, or rotates a secret. Every finding is a ticket, and unowned tickets are where audit findings go to die.
What comes next
Every sweep in this guide can run continuously instead of quarterly. Olivier, CloudThinker's security agent, connects to Keycloak with a read-only service account and watches realms, clients, roles, and events daily — alerting when a new client widens access, and changing nothing without your approval. That's part three of this series. Or see your own realm's findings first: Try CloudThinker free — 100 premium credits, no card required.
