Keycloak Service Accounts and the Client Credentials Grant
A Keycloak service account is a hidden user account attached to a confidential client, and the client credentials grant is how that client gets a token for itself with no human involved. It is what a cron job, a backend service or an Admin API script should use instead of signing in as a person.
Four commands get you one:
# 1. a confidential client with service accounts on
kcadm.sh create clients -r demo -s clientId=orders-job -s publicClient=false \
-s serviceAccountsEnabled=true -s standardFlowEnabled=false
# 2. read its secret from the Credentials tab (or clients/<uuid>/client-secret)
# 3. grant roles to the user named service-account-orders-job, not to the client
kcadm.sh add-roles -r demo --uusername service-account-orders-job \
--cclientid realm-management --rolename view-users
# 4. ask for a token
curl -X POST http://localhost:8080/realms/demo/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=orders-job -d client_secret=$SECRET
Step 3 is where it goes wrong, and not in the way you expect: on a default client the roles you grant there can be absent from the token entirely, and the fix is on a different screen. That is step 4 below.
Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1 start-dev), on 2026-10-26. Every
command, error string, JSON body and number on this page was copied from a run on that
version.
Keycloak's own Using a service account is the reference for the protocol: it gives you the toggle, the endpoint and the POST body in about 400 words, and it is correct. What it does not tell you is which roles to grant, why the token you get back has no roles in it, what toggling the switch off and on again destroys, or what to do instead of mailing a secret to whoever runs the job. That is this page.
Prerequisites
docker run -d --name kc -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.8.1 start-dev
K="docker exec kc /opt/keycloak/bin/kcadm.sh"
$K config credentials --server http://localhost:8080 \
--realm master --user admin --password admin
$K create realms -s realm=demo -s enabled=true
KC=http://localhost:8080/realms/demo/protocol/openid-connect
ADMIN=http://localhost:8080/admin/realms/demo
A small decoder, used throughout:
decode() { python3 -c "
import sys,json,base64
t=json.load(sys.stdin)['access_token']; p=t.split('.')[1]; p+='='*(-len(p)%4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(p)),indent=1))"; }
New to clients? Start with Keycloak client types: public, confidential, bearer-only — a service account only exists on a confidential client, and that page explains why.
Step 1 — Create the client and get a token
Admin console
- In the
demorealm: Clients → Create client, Client IDorders-job→ Next. - Turn Client authentication on. Without it there is no service account to have.
- Capability config: tick Service accounts roles. Turn Standard flow and Direct access grants off — a job never opens a browser and never types a password. → Next → Save.
- The secret is on the Credentials tab, which appears once Client authentication is on.
kcadm.sh
$K create clients -r demo \
-s clientId=orders-job \
-s publicClient=false \
-s serviceAccountsEnabled=true \
-s standardFlowEnabled=false \
-s directAccessGrantsEnabled=false
CID=$($K get clients -r demo -q clientId=orders-job --fields id --format csv --noquotes)
SECRET=$($K get clients/$CID/client-secret -r demo \
| python3 -c "import sys,json;print(json.load(sys.stdin)['value'])")
Turn off every flow the job does not use. A client with Standard flow still on is a second way to obtain a token in its name, and nothing about the service account needs it.
Verify it worked
curl -s -X POST $KC/token -d grant_type=client_credentials \
-d client_id=orders-job -d client_secret=$SECRET
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "Bearer",
"not-before-policy": 0,
"scope": "email profile"
}
Three things are missing on purpose, and all three are the right behaviour:
| Missing | Why |
|---|---|
refresh_token | None is issued. When the access token expires, ask for another one — that is the refresh mechanism here |
session_state / sid | No user session is created, so nothing accumulates in the session store and there is nothing to log out |
id_token | Nobody authenticated. Asking for scope=openid does return one describing the service account user, which is of no use to anything |
And the token itself, with the timestamp claims trimmed:
curl -s -X POST $KC/token -d grant_type=client_credentials \
-d client_id=orders-job -d client_secret=$SECRET | decode
{
"iss": "http://localhost:8080/realms/demo",
"aud": "account",
"sub": "f5ea4a2d-bf7a-46a2-a44b-9515a7988a48",
"typ": "Bearer",
"azp": "orders-job",
"realm_access": { "roles": ["offline_access", "uma_authorization", "default-roles-demo"] },
"resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } },
"scope": "email profile",
"clientHost": "172.17.0.1",
"preferred_username": "service-account-orders-job",
"clientAddress": "172.17.0.1",
"client_id": "orders-job"
}
client_id and azp name the caller — that is what your API should authorize on. sub is the
service account user's id, and step 5 is about why you must not store it anywhere.
client_id, clientHost and clientAddress are not standard claims and not something you
configured. They come from a built-in client scope called service_account
("Specific scope for a client enabled for service accounts") holding three session-note
mappers, which Keycloak attaches when you turn the switch on. clientAddress is the source IP
of whatever asked for the token, which is worth logging.
Every role in that token is one the job did not ask for: they come from the realm default role
default-roles-demo, which Keycloak grants to the service account like any other user. Step 4
removes them.
Step 2 — Find the user Keycloak will not show you
The service account is a real user row called service-account-<clientId>, and the normal
ways of listing users do not return it:
$K get users -r demo --fields username # [ ]
$K get users/count -r demo # { "count" : 0 }
Ask for it by name, or through the client, and it is there:
$K get users -r demo -q username=service-account-orders-job --fields username
$K get clients/$CID/service-account-user -r demo --fields id,username,enabled
[ { "username" : "service-account-orders-job" } ]
{ "id" : "f5ea4a2d-bf7a-46a2-a44b-9515a7988a48",
"username" : "service-account-orders-job", "enabled" : true }
Two consequences worth knowing before they bite:
- Your user count is not your user count. Any inventory, licence calculation or GDPR data
map built from
usersorusers/countsilently omits every service account in the realm. They hold attributes and role mappings like anyone else. See what personal data Keycloak stores. - Name the client, not the account. You cannot rename the service account user; it is
derived from the client ID.
orders-jobreads well in an audit log,client-2does not.
Step 3 — Grant the roles, and only those
Roles go on the service account user, not on the client:
$K add-roles -r demo --uusername service-account-orders-job \
--cclientid realm-management --rolename view-users --rolename query-users
For Admin API work the roles live on the built-in realm-management client. Which one you
need is not documented as a table anywhere, so here is one, measured by creating a second,
empty service account client (role-probe), granting it one role at a time and probing the
endpoints with the resulting token:
| Role granted (alone) | GET /users | GET /groups | GET /clients | GET /events | POST /users |
|---|---|---|---|---|---|
| (none) | 403 | 403 | 403 | 403 | 403 |
view-users | 200 | 200 | 403 | 403 | 403 |
query-users | 200 | 403 | 403 | 403 | 403 |
manage-users | 200 | 200 | 403 | 403 | 201 |
view-clients | 403 | 403 | 200 | 403 | 403 |
view-events | 403 | 403 | 403 | 200 | 403 |
realm-admin | 200 | 200 | 200 | 200 | 201 |
Three readings of that table:
view-usersis composite. Granting it also grantsquery-usersandquery-groups, which is why the group endpoint opens with it and not withquery-users.manage-usersimplies reading. You do not need to grantview-usersalongside it.realm-adminis the whole realm. It is one role and it ends the discussion about which roles you need, which is exactly why it keeps getting granted. A leaked token then does anything, including creating a user for whoever has it.