How To

Keycloak Audit with Native Tools: kcadm.sh, Events, and the Admin API

A hands-on Keycloak audit using only native tools — kcadm.sh and the Admin REST API. Sweep realm settings for missing brute-force protection and empty password policies, filter clients with jq for wildcard redirect URIs and public clients with password grants, review service-account role mappings for realm-admin grants, verify login and admin event capture, and check authentication flows and required actions for MFA enforcement. Exact copy-pasteable commands, a least-privilege auditor setup, and an honest look at why a point-in-time sweep decays as the realm drifts. Part two of our Keycloak security audit series.

·
keycloaksecurityiamssoauditopenidconnect
Cover Image for Keycloak Audit with Native Tools: kcadm.sh, Events, and the Admin API

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, and view-events roles from the target realm's realm-management client. 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 /auth path 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.