How To

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

A hands-on Keycloak audit in five sweeps with kcadm.sh, admin events and the Admin REST API, plus a twenty-line weekly diff script. Ends on what native tools answer and where they stop: they find drift but can't judge it.

9 min read
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 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.

Broad to narrow: realm defaults apply to every login, so they go first. Budget 2 to 4 hours for a realm with 30 to 80 clients.

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:

FieldExpectFinding if
sslRequiredexternal or allnone
bruteForceProtectedtruefalse (the default)
passwordPolicya non-empty policy stringnull: no length, no history, nothing
registrationAllowedfalse for internal realmstrue and you didn't intend self-signup
accessTokenLifespan300 to 900 secondshours, since a stolen token lives that long
ssoSessionIdleTimeoutmatches your idle-logout policymulti-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

ToolAnswersDoesn't answer
kcadm.sh getWhat the realm looks like right nowWhen it changed, or why
Login eventsWho logged in, who failed, from whereWhether the failures are an attack or a typo
Admin eventsWho changed what, from which IPWhether that change was approved or safe
Admin REST APIEverything above, scriptableAnything about intent
Weekly diffWhat's new since last weekWhether "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.

Native tooling finds the problems. It stops at the point where someone has to judge and act.

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

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.

keycloaksecurityiamssoauditopenidconnect