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.
Verify it worked
AT=$(curl -s -X POST $KC/token -d grant_type=client_credentials \
-d client_id=orders-job -d client_secret=$SECRET \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $AT" $ADMIN/users
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $AT" $ADMIN/clients
200
{"error":"HTTP 403 Forbidden"}
HTTP 403
A 403 on the endpoints the job does not use is the proof that it is scoped. Run the negative test, not just the positive one.
The Admin API authorizes on the roles in the token. Your API should also check aud, and
by default this token's audience is account. Add an audience mapper to the client so your
resource server is named in it:
$K create clients/$CID/protocol-mappers/models -r demo \
-s name=orders-api-audience -s protocol=openid-connect \
-s protocolMapper=oidc-audience-mapper \
-s 'config."included.custom.audience"=orders-api' \
-s 'config."access.token.claim"=true'
aud then reads ["orders-api", "realm-management", "account"] — your mapper plus whatever
the role mappings already resolved — and drops to ["orders-api", "realm-management"] once
step 4 is done. Full mapper reference:
Keycloak protocol mappers.
Step 4 — The roles are in the grant and not in the token
This is the one that costs an afternoon. Keycloak's documentation states the rule in one sentence — roles in an access token are the intersection of the service account's roles and the client's role scope mappings — and the consequence only shows up when you follow the other advice in the same chapter and turn Full scope allowed off:
$K update clients/$CID -r demo -s fullScopeAllowed=false
The grant from step 3 is untouched. The token is not:
{ "realm_access": null, "resource_access": null }
GET /admin/realms/demo/users → 403
Every role is gone. Nothing warns you, the service account still holds the roles in the Service accounts roles tab, and the integration starts failing with a 403 that looks like a permissions problem on the Keycloak side.
The missing half is the client's scope: a client can only put roles in its token that its scope mappings allow. Add them on the dedicated client scope:
Admin console
Clients → orders-job → Client scopes → the row reading Dedicated scope and mappers
for this client (orders-job-dedicated) → Scope → switch Full scope allowed off →
Assign role → Filter by clients → pick view-users and query-users from
realm-management.
kcadm.sh
RM=$($K get clients -r demo -q clientId=realm-management --fields id --format csv --noquotes)
ROLES=$($K get clients/$RM/roles -r demo --fields id,name | python3 -c "
import sys,json
print(json.dumps([r for r in json.load(sys.stdin)
if r['name'] in ('view-users','query-users')]))")
echo "$ROLES" | $K create clients/$CID/scope-mappings/clients/$RM -r demo -f -
Now the token says exactly what the job may do, and nothing else (aud also carries
orders-api if you added the audience mapper from step 3):
{
"aud": "realm-management",
"azp": "orders-job",
"resource_access": {
"realm-management": { "roles": ["view-users", "query-groups", "query-users"] }
}
}
GET /admin/realms/demo/users → 200
Note what left with the clutter: offline_access, uma_authorization and the three account
roles are gone, because they are not in the client's scope either. They came from the realm
default role, which Keycloak grants to a service account the way it grants it to a person, so
finish the job by taking that away too:
$K remove-roles -r demo --uusername service-account-orders-job \
--rolename default-roles-demo
Measured on this one client, the default configuration issued a 1,389-character access token and the tuned one issues 1,159 for the same request. Size is the small win; the point is that what is left is exactly the two roles the job uses, so a leaked token can do exactly that much.
Keycloak 26.8.1 calls Full scope allowed deprecated in the admin guide and says it will be removed. Do this now, on every service account client, rather than finding out on an upgrade. To stop new clients being created with it on, use the Full Scope Disabled executor in a client policy.
Step 5 — Do not toggle the switch off "just to test"
Turning Service accounts roles off deletes the service account user. Turning it back on creates a new one. The client, its ID and its secret are unchanged throughout, so nothing looks like it moved:
$K update clients/$CID -r demo -s serviceAccountsEnabled=false
$K update clients/$CID -r demo -s serviceAccountsEnabled=true
$K get clients/$CID/service-account-user -r demo --fields id,username
{ "id" : "3165d00c-5e3a-494f-a75d-5087fe3781ba",
"username" : "service-account-orders-job" }
A different id from the one in step 2, and:
$K get-roles -r demo --uusername service-account-orders-job \
--cclientid realm-management --effective
[]
Every role grant is gone, and the job gets a 403 with a secret that still authenticates perfectly. Two rules follow:
- Treat the toggle as destructive. Re-granting the roles is the recovery, and you need a record of what they were. The audit command at the end of this page prints it.
- Never store a service account's
subanywhere. If rows in your database are owned by that user id, they are orphaned the moment someone flips the switch. Identify the caller byclient_id.
Step 6 — Sessions, revocation, and caching the token
By default a service account token creates nothing:
$K get clients/$CID/session-count -r demo # { "count" : 0 }
There is a client attribute that changes this — Use refresh tokens for client credentials
grant on the Advanced tab, client_credentials.use_refresh_token — and it looks helpful. It
is not. With it on, six token requests produced six sessions:
$K update clients/$CID -r demo \
-s 'attributes."client_credentials.use_refresh_token"=true'
# …six token requests…
$K get clients/$CID/session-count -r demo # { "count" : 6 }
A job fetching a token every minute against the default 30-minute SSO idle timeout therefore keeps around 30 live sessions permanently, per client, for no benefit: the refresh token saves nothing over asking for a new access token with the same secret. Leave it off. The session and token timeouts page covers what those idle timeouts do to the store.
Having no session does not mean a token cannot be withdrawn. The revocation endpoint works:
curl -s -o /dev/null -w "before: %{http_code}\n" -H "Authorization: Bearer $AT" $ADMIN/users
curl -s -o /dev/null -w "revoke: %{http_code}\n" -X POST $KC/revoke \
-d client_id=orders-job -d client_secret=$SECRET \
-d token_type_hint=access_token -d "token=$AT"
curl -s -o /dev/null -w "after: %{http_code}\n" -H "Authorization: Bearer $AT" $ADMIN/users
before: 200
revoke: 200
after: 401
Introspecting a service account token from the client that owns it returns {"active":false}
whether the token is live or revoked, because since 26.6.2 the introspecting client must be in
the token's aud and being azp does not count. The server log says
Client 'orders-job' is not in the token audience; the response says nothing. Details in
Keycloak client types.
Cache the token. expires_in is 300 seconds by default and settable per client
(access.token.lifespan, or Advanced → Advanced settings → Access Token Lifespan).
Requesting a fresh one per API call measured a median of 13 ms against a container on the
same machine, before any real network is involved, and every one of those requests makes the
server sign a token nobody needed. Hold the token until shortly before exp, then re-request.
Step 7 — Stop shipping the secret
A shared secret has to be generated, transported to whoever runs the job, kept in its
environment, and rotated by two parties at once. Keycloak 26.8.1 ships five client
authenticators — kcadm.sh get authentication/client-authenticator-providers -r demo lists
them on your version — and four of them are candidates for a service account:
clientAuthenticatorType | Console name | What the job holds | Secret crosses the wire | Good for |
|---|---|---|---|---|
client-secret | Client Id and Secret | the secret | yes, every request | getting started; anything behind a vault |
client-secret-jwt | Signed JWT with Client Secret | the secret | no, an HMAC-signed assertion | a cheap improvement with no key management |
client-jwt | Signed JWT | a private key | no, nothing shared exists | the one to aim for |
client-x509 | X509 Certificate | a client certificate | no, mutual TLS | estates that already issue certificates |
The fifth, federated-jwt (Signed JWT - Federated), validates an assertion issued by an
external identity provider rather than by the client, which is a different problem from this
one.
client-jwt (RFC 7523 private_key_jwt) is worth the twenty lines. Keycloak never holds
anything that can impersonate the client, so there is no shared secret to rotate or leak.
openssl req -x509 -newkey rsa:2048 -keyout sa-key.pem -out sa-cert.pem \
-days 365 -nodes -subj "/CN=orders-job"
CERT=$(openssl x509 -in sa-cert.pem -outform DER | base64 -w0)
$K update clients/$CID -r demo \
-s clientAuthenticatorType=client-jwt \
-s "attributes.\"jwt.credential.certificate\"=$CERT"
In the console that is Credentials → Client Authenticator → Signed JWT, then the Keys tab to generate or import the key pair.
The job now signs a short-lived assertion instead of sending a password:
b64url() { base64 -w0 | tr '+/' '-_' | tr -d '='; }
now=$(date +%s)
header='{"alg":"RS256","typ":"JWT"}'
payload=$(printf '{"iss":"%s","sub":"%s","aud":"%s","exp":%s,"iat":%s,"jti":"%s"}' \
orders-job orders-job http://localhost:8080/realms/demo \
"$((now+60))" "$now" "$(cat /proc/sys/kernel/random/uuid)")
input="$(printf '%s' "$header" | b64url).$(printf '%s' "$payload" | b64url)"
sig=$(printf '%s' "$input" | openssl dgst -sha256 -sign sa-key.pem -binary | b64url)
ASSERTION="$input.$sig"
curl -s -X POST $KC/token -d grant_type=client_credentials \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d "client_assertion=$ASSERTION"
That returns the same token as before. Three behaviours to design around, all tested:
- The old secret stops working, with
{"error":"invalid_client","error_description":"Parameter client_assertion_type is missing"}. Switch the authenticator and the job's code in the same change. - Assertions are single use. Replaying one answers
{"error":"invalid_client","error_description":"Token reuse detected"}. Build a fresh assertion with a freshjtiper token request; do not cache it. (Cache the access token — that is step 6.) audmust be the realm issuer URL or the token endpoint URL. Anything else is{"error":"invalid_client","error_description":"Invalid token audience"}.
Step 8 — Know whether anyone is still using it
Before deleting a service account that looks abandoned, ask the server. Login events record every client credentials grant:
$K update events/config -r demo -s eventsEnabled=true \
-s 'enabledEventTypes=["CLIENT_LOGIN","CLIENT_LOGIN_ERROR"]' -s eventsExpiration=2592000
$K get events -r demo
With the realm and timestamp fields trimmed, a successful grant and a failed one:
{"type": "CLIENT_LOGIN", "clientId": "orders-job", "ipAddress": "172.17.0.1",
"details": {"grant_type": "client_credentials", "client_auth_method": "client-jwt",
"username": "service-account-orders-job", "scope": "email profile",
"client_assertion_id": "516f73c4-513d-4aeb-a738-9550e13cbbdd",
"client_assertion_issuer": "orders-job", "client_assertion_sub": "orders-job",
"client_jwt_kid": "erIi7ADziIa-ZWC2TOSghdNWUzGoCZ0x0dqgq-BjVx0"}}
{"type": "CLIENT_LOGIN_ERROR", "clientId": "orders-job", "ipAddress": "172.17.0.1",
"details": {"grant_type": "client_credentials"}, "error": "invalid_client_credentials"}
client_auth_method tells you which clients are still on client-secret after a migration,
ipAddress tells you where the job actually runs, and a client with no CLIENT_LOGIN across
the retention window — 30 days in the eventsExpiration above — is a candidate for deletion.
CLIENT_LOGIN_ERROR with invalid_client_credentials during a rotation means somebody is
still holding the old secret.
Note what the client-jwt client contributes that a secret cannot: the assertion's jti, its
issuer and subject, and the kid of the key that signed it. When two deployments share a
client ID, that is how you tell which one called — and it is a second argument for step 7
beyond not shipping the secret.
When it goes wrong
| What you see | What it means |
|---|---|
unauthorized_client / Client not enabled to retrieve service account | Service accounts roles is off on the client |
unauthorized_client / Public client not allowed to retrieve service account | The client is public. A service account needs a credential |
unauthorized_client / Invalid client or Invalid client credentials | Wrong, missing or stale secret — all three answer identically on purpose |
invalid_client / Parameter client_assertion_type is missing | The client was switched to client-jwt and the caller is still sending a secret |
invalid_client / Token reuse detected | The same client_assertion was sent twice. One assertion per request |
invalid_client / Invalid token audience | The assertion's aud is not the realm issuer or token endpoint URL |
Token arrives, realm_access and resource_access are absent | Full scope allowed is off and the roles are not in the client's scope mappings. Step 4 |
| 403 from the Admin API with roles visible in the console | Same cause, or the service account was recreated by a toggle. Steps 4 and 5 |
| 401 from the Admin API on a token that worked a minute ago | It expired (expires_in, default 300) or was revoked |
{"active": false} introspecting your own service account token | The audience rule, not the token. Step 6 |
| Session count climbing on a client with no users | client_credentials.use_refresh_token is on. Step 6 |
Verify your setup
Audit the whole realm before you call this done. First, every client with a service account, how its scope is set, and how it authenticates:
$K get clients -r demo --format csv \
--fields clientId,serviceAccountsEnabled,fullScopeAllowed,clientAuthenticatorType
"account",false,false,"client-secret"
"account-console",false,false,"client-secret"
"admin-cli",false,true,"client-secret"
"broker",false,false,"client-secret"
"orders-job",true,false,"client-jwt"
"realm-management",false,false,"client-secret"
"role-probe",true,true,"client-secret"
"security-admin-console",false,true,"client-secret"
Keycloak's own built-in clients all answer false in the second column. Anything else that
says true is a machine identity somebody created, and it should be on this list on purpose.
Then what each of those service accounts can actually do — and keep the output, because step 5 is why you will want it:
for c in $($K get clients -r demo --fields clientId,serviceAccountsEnabled | python3 -c "
import sys,json
print(' '.join(c['clientId'] for c in json.load(sys.stdin)
if c.get('serviceAccountsEnabled')))"); do
echo "-- $c"
$K get-roles -r demo --uusername service-account-$c --all | python3 -c "
import sys,json
d=json.load(sys.stdin)
print(' realm:', [r['name'] for r in d.get('realmMappings',[])] or '-')
for k,v in d.get('clientMappings',{}).items():
print(' ', k+':', [r['name'] for r in v.get('mappings',[])])"
done
-- orders-job
realm: -
realm-management: ['query-users', 'view-users']
-- role-probe
realm: -
Five questions, one per default this page argues against:
- Does any service account hold
realm-admin? Replace it with the roles from step 3's table. - Is
fullScopeAllowedfalse on every service account client, with explicit scope mappings? - Does
default-roles-demoneed to be on a service account at all? Usually not. - Is
client_credentials.use_refresh_tokenoff everywhere? - Is anything still on
client-secretthat could be onclient-jwt?
Next steps
- Keycloak client types: public, confidential, bearer-only — why only a confidential client can have a service account.
- Keycloak roles, composite roles, and groups — the objects you are granting, and what composite means.
- Keycloak protocol mappers: a complete field guide — the audience mapper in step 3, and everything else that writes a claim.
- Use kcadm.sh: the Keycloak admin CLI — the client that service accounts replace, and the rest of the CLI.
- Keycloak fine-grained admin permissions V2 —
when
view-usersover the whole realm is still too much. - Which Keycloak clients should be enabled when you go live? — turning off the password grant your service account just replaced.
- Official reference: Using a service account and Role scope mappings.
- Clean up:
docker rm -f kc.