Skip to main content

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.

Tested against

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
  1. In the demo realm: Clients → Create client, Client ID orders-job → Next.
  2. Turn Client authentication on. Without it there is no service account to have.
  3. 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.
  4. 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:

MissingWhy
refresh_tokenNone is issued. When the access token expires, ask for another one — that is the refresh mechanism here
session_state / sidNo user session is created, so nothing accumulates in the session store and there is nothing to log out
id_tokenNobody 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 users or users/count silently 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-job reads well in an audit log, client-2 does 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 /usersGET /groupsGET /clientsGET /eventsPOST /users
(none)403403403403403
view-users200200403403403
query-users200403403403403
manage-users200200403403201
view-clients403403200403403
view-events403403403200403
realm-admin200200200200201

Three readings of that table:

  • view-users is composite. Granting it also grants query-users and query-groups, which is why the group endpoint opens with it and not with query-users.
  • manage-users implies reading. You do not need to grant view-users alongside it.
  • realm-admin is 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.

Calling your own API, not Keycloak's

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 sub anywhere. If rows in your database are owned by that user id, they are orphaned the moment someone flips the switch. Identify the caller by client_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
Introspection is not how you check this

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:

clientAuthenticatorTypeConsole nameWhat the job holdsSecret crosses the wireGood for
client-secretClient Id and Secretthe secretyes, every requestgetting started; anything behind a vault
client-secret-jwtSigned JWT with Client Secretthe secretno, an HMAC-signed assertiona cheap improvement with no key management
client-jwtSigned JWTa private keyno, nothing shared existsthe one to aim for
client-x509X509 Certificatea client certificateno, mutual TLSestates 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 fresh jti per token request; do not cache it. (Cache the access token — that is step 6.)
  • aud must 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 seeWhat it means
unauthorized_client / Client not enabled to retrieve service accountService accounts roles is off on the client
unauthorized_client / Public client not allowed to retrieve service accountThe client is public. A service account needs a credential
unauthorized_client / Invalid client or Invalid client credentialsWrong, missing or stale secret — all three answer identically on purpose
invalid_client / Parameter client_assertion_type is missingThe client was switched to client-jwt and the caller is still sending a secret
invalid_client / Token reuse detectedThe same client_assertion was sent twice. One assertion per request
invalid_client / Invalid token audienceThe assertion's aud is not the realm issuer or token endpoint URL
Token arrives, realm_access and resource_access are absentFull 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 consoleSame 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 agoIt expired (expires_in, default 300) or was revoked
{"active": false} introspecting your own service account tokenThe audience rule, not the token. Step 6
Session count climbing on a client with no usersclient_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:

  1. Does any service account hold realm-admin? Replace it with the roles from step 3's table.
  2. Is fullScopeAllowed false on every service account client, with explicit scope mappings?
  3. Does default-roles-demo need to be on a service account at all? Usually not.
  4. Is client_credentials.use_refresh_token off everywhere?
  5. Is anything still on client-secret that could be on client-jwt?

Next steps​