Guide

How to run a user access review in Keycloak

Run a user access review when your identity provider is Keycloak: what to collect, where access hides, and a tested script that lists who holds a role.

Part of the guide User Access Review: How to Run One, Step by Step

15 min readLast updated

A user access review asks one question about every account: should this person still be able to do this? You list who can get into what. A person who knows the work says yes or no for each item. You keep the record. The user access review guide covers the whole process. This page covers the first step when your identity provider is Keycloak: getting a list you can trust.

That step is harder in Keycloak than it looks. Keycloak is an open-source identity provider. It holds your users and decides what each of them may do in your apps. Access there is built in layers, and the screens you reach first show only the top layer. We ran the script on this page against Keycloak 26.8.1 and 26.4.7, and every other command against 26.8.1, on a throwaway realm built for it.

Six Keycloak words come up all the time:

  • A realm is one walled-off set of users, groups, roles and apps. You review each realm on its own.
  • A client is an app or service that uses Keycloak to sign people in.
  • A role is a named permission, such as billing-admin. A realm role belongs to the whole realm. A client role belongs to one client.
  • A group is a set of users. A group can have subgroups.
  • A composite role is a role that contains other roles. Whoever holds it holds them all.
  • A service account is a login for a program, not a person.

What to collect

Collect these for every realm you review, on one day, and write the day down. A review describes a moment, and Keycloak keeps changing while you work. Every path below sits under /admin/realms/{realm} on your Keycloak address.

  • People. Username, email, whether the account is enabled, and when it was created. Path: users.
  • Groups and subgroups. The tree, the path of each group (such as /Finance/Admins) and its members. Paths: groups, groups/{id}/children, groups/{id}/members.
  • Roles. Every realm role and every client role, and the client each client role belongs to. Paths: roles, clients, clients/{id}/roles.
  • Composite roles. What each composite role contains. Path: roles/{name}/composites.
  • The default role. The role every new user gets. It is usually called default-roles- followed by the realm name, but it can be renamed. The sure way to find it is GET /admin/realms/{realm}, which names it in defaultRole.name.
  • Role mappings. A role mapping is the link that gives a role to a person or to a group. Collect both kinds, and group membership too. Paths: users/{id}/role-mappings, groups/{id}/role-mappings, users/{id}/groups.
  • Service accounts. The clients that have one, and the roles each holds. Paths: clients, clients/{id}/service-account-user.
  • Links to other identity providers, if people sign in through one. Path: users/{id}/federated-identity.
  • Last sign-in, if the realm stores it. See the section on last sign-in below.

Keycloak can also work out the combined answer. The path users/{id}/role-mappings/realm/composite returns every realm role a person ends up holding, through groups and composite roles too. We checked that it includes roles that arrive through a group. Still collect the direct mappings: they show where a fix has to be made.

Where access hides

Groups pass roles down

A group’s roles go to every member. They also go to every member of every subgroup. Keycloak’s documentation on groups says subgroups inherit the role mappings of their parent. In our test realm, Ravi sits in /Finance/Interns. That group holds no role. Ravi still holds viewer, because /Finance, one level up, holds it.

Composite roles nest

A composite role is a bundle, and a bundle can hold other bundles. The documentation says the inheritance is recursive. In our test realm, finance-power contains payments-admin, which contains billing-admin, which contains a client role, billing-approve. A person who holds only finance-power can approve bills. A list of direct roles never names billing-approve.

The default role reaches everyone

Each new user gets a default role. In the versions we tested it is a composite role, usually named default-roles-<realm>. It contains two built-in roles, offline_access, which lets an app keep a long-lived token for a person, and uma_authorization, which belongs to Keycloak’s User-Managed Access feature. It also holds two roles of the account client, which let people manage their own account. It is given to each user as an ordinary role mapping. So if someone adds a role to it later, every user holds that role at once, existing users included. We tried it: after we added one role to the default role, all seven people and every service account held it. Read what is in it.

Service accounts are not in the user list

Keycloak creates a service account for a client when you switch on Service account roles. It has its own user record, and that record can hold roles and belong to groups like a person’s. The users list leaves these records out. We saw that on both versions. Find them by listing the clients and keeping the ones that have serviceAccountsEnabled set. In the Admin Console they are on the client’s Service account roles tab. A deploy key that holds billing-admin has as much access as a person who does.

Admin access is a role too

Each realm has a client named realm-management. Its roles let people change that realm: manage-users, manage-clients, manage-realm, impersonation and others. The role realm-admin is a composite of all of them, so a person can hold manage-users without holding realm-admin. The role impersonation lets its holder sign in as another user of the realm, with that user’s access. In the master realm, there is one such client per other realm, named <realm>-realm. People in master can manage another realm through its roles, and the master realm role admin contains them. Review who holds these first. They are privileged access: the keys to everything else.

Disabled people keep their roles

A disabled account cannot sign in, but it keeps its roles and group memberships. In our test realm, Pat is disabled and still holds billing-admin. Keep disabled people in the list and mark them. Someone has to decide to remove the roles, and if the account is switched on again, the access comes back with it.

People who come from a directory

If you use user federation, Keycloak reads people from LDAP or Active Directory. A person imported that way has a federationLink field that names the provider. Taking them out of the directory does not take them out of Keycloak at once. Keycloak notices when it next looks the person up and the copy is not in its cache. Then, by the provider’s setting Remove invalid users during searches, it either deletes the local user or keeps it as read-only and disabled. Until then the local user and its roles are still there.

People who sign in through another provider

Some companies let people sign in to Keycloak through another identity provider, such as a company directory or a social login. Keycloak then keeps a link between its own user and the person at that provider. The call users/{id}/federated-identity lists the links. The local user stays, with its roles, when the person is taken out at the outside provider.

For both, compare the two lists: the Keycloak users and the people the directory or provider says are current. A Keycloak user with no matching person is an orphaned account.

Client scopes change tokens, not holders

A role scope mapping limits which of a person’s roles go into the token a client receives. It does not give anyone a role. By default, the documentation says, each client gets all the role mappings of the user. So scope mappings are not part of who holds what. They answer a different question: can this app see this role. Neither the script nor the collector reads them.

Last sign-in

A reviewer decides better when they can see that an account has not been used for months. Keycloak can tell you, but only if events were switched on beforehand.

Keycloak does not store login events by default, its documentation says. You switch on Save events for the realm, under Realm settings, Events, User events settings, and set how long to keep them in the Expiration field. We saw the same: with events off, a sign-in left nothing behind, and a sign-in made before you switch events on is never recorded. So there are three cases:

  • Events are off. Last sign-in is unknown. Write “unknown”. Never write “never”.
  • Events are on. The newest LOGIN event of a person is their last sign-in, as far back as the store reaches. A person with no LOGIN event was not seen in that time. The time is at most your Expiration setting, and less if events were switched on recently.
  • The read was refused. A reader without the view-events role gets an error, not an empty list. Last sign-in is unknown until you fix the role.

Programs sign in differently. A service account that uses the client credentials grant leaves CLIENT_LOGIN events, not LOGIN. These two commands read the setting and one person’s newest sign-in:

kcadm.sh get events/config -r corp --fields eventsEnabled,eventsExpiration
kcadm.sh get events -r corp -q type=LOGIN -q user=$USER_ID --limit 1 --fields time,type

The first prints whether events are on and how long they are kept, in seconds. In the second, $USER_ID is the person’s id from the users list. It prints the newest event, or an empty list. Its time is in milliseconds since 1970, so divide by 1,000 to get a date.

A tested script: who holds a role

The REST call roles/{name}/users lists only the people who were given the role directly. In our test realm, billing-admin is held directly by Marcus, Pat and one service account. Dana holds it too, through a group and two composite roles. The direct list leaves her out. That is how a list that looks complete misleads a reviewer.

The script below asks Keycloak for each account’s effective roles instead, and keeps the accounts that hold the role. It only reads.

Set up a read-only reader

  1. In the Admin Console, open the realm you review. Under Clients, create a client named acciew-reader. Switch on Client authentication and Service account roles, and switch off the other sign-in flows.
  2. On the client’s Service account roles tab, choose Assign role, filter by clients, and add view-users, view-clients, view-realm and view-events from realm-management. If the client lives in the master realm, use the roles of the client named <realm>-realm.
  3. On the Credentials tab, copy the client secret into your shell: export KC_SECRET=....

These are the same four roles the Acciew collector asks for. A manage-* role also works, but it gives more than you need.

Note: If you switch off Full scope allowed

Keycloak 26.8 deprecates Full scope allowed, and its console recommends switching it off. Then add the same four roles to the client’s dedicated scope (Client scopes tab, then Scope tab). The token carries only the roles the account holds that are also listed in the scope. Without this, every call answered 403 when we tried.

The script

#!/usr/bin/env bash
# Who holds one role, by any route: directly, through a group, or through a composite.
# Reads only. Needs bash, curl and jq.
set -euo pipefail

KC=${KC:-https://sso.example.com}   # Keycloak 17 or later: no /auth in the address
REALM=${REALM:-corp}                # the realm you are reviewing
AUTH_REALM=${AUTH_REALM:-$REALM}    # the realm the reader client lives in
CLIENT_ID=${CLIENT_ID:-acciew-reader}
ROLE=${ROLE:-billing-admin}         # the role to look for
APP=${APP:-}                        # a clientId, to look for a client role instead
: "${KC_SECRET:?export KC_SECRET first}"   # the reader client's secret

token() {
  curl -sf "$KC/realms/$AUTH_REALM/protocol/openid-connect/token" \
    -d grant_type=client_credentials -d client_id="$CLIENT_ID" \
    -d client_secret="$KC_SECRET" | jq -r .access_token ||
    { echo "No token. Check KC, AUTH_REALM, CLIENT_ID and KC_SECRET." >&2; return 1; }
}
last=-999
refresh() {   # a token lasts 60 seconds in master and 5 minutes elsewhere: renew after 30
  if (( SECONDS - last > 30 )); then TOKEN=$(token); last=$SECONDS; fi
}
api() {       # curl -f fails on an error, and that stops the script
  curl -sf -H "Authorization: Bearer $TOKEN" "$KC/admin/realms/$REALM/$1" ||
    { echo "Keycloak refused: $1" >&2; return 1; }
}

# Every page of a list. Keycloak cuts a list at its page size and does not say so.
all() {
  local path=$1 filter=$2 first=0 sep='?' page
  case $path in *\?*) sep='&' ;; esac
  while :; do
    refresh
    page=$(api "$path${sep}first=$first&max=100")
    jq -r "$filter" <<<"$page"
    if [ "$(jq length <<<"$page")" -lt 100 ]; then break; fi
    first=$((first + 100))
  done
}

uri() { jq -rn --arg v "$1" '$v | @uri'; }   # a role or client name may hold spaces

refresh
if [ -n "$APP" ]; then
  cid=$(api "clients?clientId=$(uri "$APP")" | jq -r '.[0].id')
  MAP="role-mappings/clients/$cid"
else
  MAP="role-mappings/realm"
fi
if ! api "${cid:+clients/$cid/}roles/$(uri "$ROLE")" > /dev/null 2>&1; then
  echo "There is no role $ROLE${APP:+ in the client $APP} in the realm $REALM. Stopping." >&2
  exit 1
fi

holds() { # holds <user id> <name> <kind> <state>
  local direct effective how
  refresh
  effective=$(api "users/$1/$MAP/composite" | jq --arg r "$ROLE" 'any(.[]; .name == $r)')
  [ "$effective" = true ] || return 0
  direct=$(api "users/$1/$MAP" | jq --arg r "$ROLE" 'any(.[]; .name == $r)')
  if [ "$direct" = true ]; then how=direct; else how="through a group or a composite role"; fi
  printf '%s\t%s\t%s\t%s\n' "$2" "$3" "$4" "$how"
}

# Service accounts are clients with a service account switched on. Listing the clients
# needs view-clients, and without it Keycloak sends [] and no error, so check first.
mgmt=realm-management
if [ "$REALM" = master ]; then mgmt=master-realm; fi
if [ "$(api "clients?clientId=$mgmt" | jq length)" != 1 ]; then
  echo "Cannot see the client $mgmt. Stopping: the client list is not whole." >&2
  exit 1
fi
all 'clients' '.[] | select(.serviceAccountsEnabled) | [.id, .clientId] | @tsv' > robots.tsv

# People. Service accounts are not in this list.
all 'users?briefRepresentation=true' '.[] | [.id, .username, .enabled] | @tsv' > people.tsv
people=$(wc -l < people.tsv | tr -d ' ')
expected=$(api users/count)
# With query-users but not view-users, Keycloak sends [] and no error.
if [ "$people" != "$expected" ]; then
  echo "Read $people people, Keycloak counts $expected. Stopping: this list is not whole." >&2
  exit 1
fi

while IFS=$'\t' read -r id name enabled; do
  holds "$id" "$name" person "$([ "$enabled" = true ] && echo enabled || echo DISABLED)"
done < people.tsv

while IFS=$'\t' read -r id name; do
  account=$(api "clients/$id/service-account-user" | jq -r .id)
  holds "$account" "$name" "service account" enabled
done < robots.tsv

echo "Checked $people people and $(wc -l < robots.tsv | tr -d ' ') service accounts." >&2

Run it

Save it as who-holds-a-role.sh and make it runnable with chmod +x who-holds-a-role.sh. Then give it the secret and name the role:

export KC_SECRET=...   # from step 3 above
KC=https://sso.example.com REALM=corp ROLE=billing-admin ./who-holds-a-role.sh

On our test realm it printed this:

dana         person           enabled    through a group or a composite role
marcus       person           enabled    direct
pat          person           DISABLED   direct
ci-deployer  service account  enabled    direct
Checked 7 people and 2 service accounts.

The script also leaves two files in the folder, people.tsv and robots.tsv. They are the lists it checked. Keep them with the review.

Read the output like a reviewer would. Dana is the person the direct list misses. Pat is disabled and still holds the role. ci-deployer is a program, not a person, and someone should be able to say who owns it.

For a client role, set APP to the client and ROLE to the role: APP=billing-prod ROLE=billing-approve.

Who can administer the realm

Run the script once for each management role you care about. A run for manage-users also lists the people who hold realm-admin, because realm-admin contains it. A run for realm-admin alone misses the people who hold only a part. In our test, Kim holds realm-admin, and we gave Mia only manage-users. The realm-admin run listed Kim. The manage-users run listed both.

for r in manage-users manage-clients manage-realm impersonation; do
  echo "== $r"; APP=realm-management ROLE=$r ./who-holds-a-role.sh
done

Each run reads every account again, so a large realm takes a while. People in the master realm can manage other realms through the <realm>-realm client there. Run REALM=master APP=corp-realm ROLE=manage-users and the same for the other roles, and REALM=master ROLE=admin for the people who administer master itself. The reader then needs the four view roles from the master-realm client, and its client must live in the master realm. The <realm>-realm clients have no realm-admin role, so a run for it lists nobody. The script stops and says the role is not there. Use the management roles.

Why does Dana have it?

The script says whether she holds the role, and whether it was direct. It does not say by which route. To find the route, ask these questions in turn. The kc line makes the output plain text.

kc() { kcadm.sh "$@" --format csv --noquotes; }
kcadm.sh config credentials --server https://sso.example.com --realm corp \
  --client acciew-reader --secret "$KC_SECRET"

kc get-roles -r corp --uusername dana --fields name                  # default-roles-corp
kc get users/$DANA/groups -r corp --fields id,path                   # /Finance/Admins
kc get groups/$GROUP_ID/role-mappings/realm -r corp --fields name    # finance-power
kc get roles/finance-power/composites -r corp --fields name          # payments-admin
kc get roles/payments-admin/composites -r corp --fields name         # billing-admin

$DANA is her user id, from kc get users -r corp -q username=dana -q exact=true --fields id. $GROUP_ID is the id printed by the second question. The route is Dana, then /Finance/Admins, then finance-power, then payments-admin, then billing-admin. Note that get-roles --uusername dana lists only her direct role. Add --effective and it lists everything she ends up holding.

The second question lists only the groups she was put in, not their parents. If that group holds no role itself, look one level up the path, at /Finance, then at its parent, and so on. For a top-level group, kc get-roles -r corp --gpath /Finance --fields name works. For a group further down, find its id with kc get groups -r corp -q search=Name -q populateHierarchy=false --fields id,path and use groups/$ID/role-mappings/realm. Without the last -q, the search returns the top-level parent, /Finance, and not the group you looked for.

What makes a list too short

Five things can spoil a list or stop a run. The script guards against each one.

  • Page sizes. On both versions we tested, users returns 100 results unless you ask for more, and groups/{id}/children returns 10. On 26.8.1, roles/{name}/users and groups/{id}/members also stop at 100. The script asks page by page until a page comes back short. kcadm.sh get users also stops at 100 unless you pass --limit.
  • A missing view role. With only the query-* roles, GET users answered 200 with an empty list, while users/count said 7. Keycloak did not refuse. It sent less. The script compares the two numbers and stops when they differ. Do the same in anything you write.
  • No view-clients. The client list does the same: with query-clients and not view-clients, it answered 200 with an empty list. The script stops unless it can see the realm’s management client, and it checks before it prints anything.
  • A role that is not there. A misspelled role name finds nobody, and Keycloak raises no error. The script checks that the role exists and stops if it does not.
  • A short token life. A token lasts 60 seconds by default in the master realm and 5 minutes in other realms. The script gets a new one when its token is 30 seconds old.

The easy path: the open-source collector

The script answers one question about one role. A review needs the whole list, every account with every access, and the route for each. That is what the Keycloak collector does. It is open source (Apache-2.0) and lives at github.com/acciew/collectors.

It reads realms, users, groups, realm and client roles, composite roles and role mappings through the Admin REST API. A grant that arrives through a group, a subgroup or a composite role is reported as effective, with the route it came by. It never writes. It needs one service account holding the four roles of each realm it reads. The view-events role is optional: it is only for last sign-in. The technical details list what it reads and why, and the Keycloak page shows what a review looks like.

It is built to fail rather than hand you a short list. If a role is missing, the check says which. If it cannot tell what its token may read, it stops and says how to fix it. A collection that did not read everything is not reported as whole.

What it does not read: Keycloak organizations, identity providers (so not the links described above), client scopes, authentication flows, user credentials, sessions and tokens. It is tested with Keycloak 26.4.7.

After the reviewers decide

When a reviewer rejects an access, someone has to remove it in Keycloak. Take it away where it was given. A role given directly is removed on the person’s page. A role that came from a group cannot be taken off on the person’s page. It goes when the person leaves the group or the group loses the role, and the second changes it for everyone in the group. Then run the script again. The person should be gone from the list. Keep both lists and the dates. The user access review checklist lists the steps before, during and after a review, and the free template is a place to keep the record.

Where Acciew fits

Acciew builds the review and the evidence on top of the collector’s list. It asks each manager to approve, reject or change each access, from a link in an email. It builds an evidence pack at the end: a spreadsheet with a row for every access, a report, the logs of what was collected and what was done, and a manifest. Acciew only reads. Your admins remove access in Keycloak. Acciew is in early access, with design partners first.

Have an access review coming up?

Acciew is in early access. We are looking for design partners: teams with a user access review ahead of them.

Apply as a design partner

Questions and answers

Can I do this in the Admin Console instead of with a script?

For a few people, yes. Open a user and look at the Role mapping tab. Keycloak lists the roles that come from groups and composite roles too, marked as inherited. The console does not answer the question the other way round, which people hold this role in the end. For that you need the API or a collector.

Will these commands keep working on other Keycloak versions?

We ran them on Keycloak 26.8.1 and 26.4.7. Keycloak documents its Admin REST API, but a new version can change a path, a page size or a default. Run the script on a test realm after each upgrade, and compare its answer with what the Admin Console shows for one person.

How do I find out when someone last logged in to Keycloak?

Only from stored login events. Keycloak does not store them unless you switch on Save events for the realm, and it keeps them only for the time you set. If events are off, the answer is unknown. If they are on and a person has no login event, they were not seen in the time the events cover. That does not mean never.

How often should I review Keycloak access?

Your framework sets the minimum. PCI DSS v4.0.1 requirement 7.2.4 says user accounts are reviewed at least once every six months. Accounts used by programs, such as Keycloak service accounts, fall under requirement 7.2.5.1, which leaves the interval to your targeted risk analysis. ISO/IEC 27001:2022 Annex A 5.18 says access rights are reviewed under your own access control policy, so your policy sets the interval. Many teams review the realm admin roles more often than the rest. That is a choice, not a rule.

Does Acciew change anything in Keycloak?

No. The collector only reads, with four view roles. When a reviewer says an access should go, your own admins remove it in Keycloak. Acciew records the decision and does not remove access.