Get a Keycloak Token and Read Every Claim
To get a token from Keycloak, POST to the realm's token endpoint:
curl -s -X POST http://localhost:8080/realms/demo/protocol/openid-connect/token \
-d client_id=demo-app \
-d username=alice \
-d password=s3cret \
-d grant_type=password | jq .
Yes, Keycloak uses JWTs. The access_token and id_token it returns are signed JSON Web
Tokens you can decode and read. The refresh_token is also a JWT, but it is not yours to
read — more on that below.
The rest of this page is the part that actually matters: what every claim in that token
means, why the aud claim is almost never what you expect, and what breaks when you get
it wrong.
Keycloak 26.7.3, started per Run Keycloak locally. Every command, every JSON body, and every measured number below is copied from an actual run against that version.
Prerequisites
A realm called demo with a client demo-app and a user alice. If you don't have one,
Your first realm, client, and user
builds it in about five minutes — or paste this:
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.7.3 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
$K create clients -r demo -s clientId=demo-app -s publicClient=true \
-s 'redirectUris=["http://localhost:5173/*"]' \
-s 'webOrigins=["http://localhost:5173"]' \
-s directAccessGrantsEnabled=true
$K create users -r demo -s username=alice -s enabled=true \
-s email=alice@example.com -s emailVerified=true \
-s firstName=Alice -s lastName=Example
$K set-password -r demo --username alice --new-password s3cret
You also want jq. Every decode below uses it.
grant_type=password gets you a token in one request, which is why it is used here. It is
discouraged in OAuth 2.1 and should not survive into your application — browser and mobile
apps use the authorization code flow with PKCE. Everything you learn about the token itself
applies identically; only the way you obtained it differs.
Step 1 — Ask for a token
Where do the endpoint URLs come from? The realm publishes them:
curl -s http://localhost:8080/realms/demo/.well-known/openid-configuration \
| jq '{issuer, token_endpoint, userinfo_endpoint, jwks_uri, introspection_endpoint}'
{
"issuer": "http://localhost:8080/realms/demo",
"token_endpoint": "http://localhost:8080/realms/demo/protocol/openid-connect/token",
"userinfo_endpoint": "http://localhost:8080/realms/demo/protocol/openid-connect/userinfo",
"jwks_uri": "http://localhost:8080/realms/demo/protocol/openid-connect/certs",
"introspection_endpoint": "http://localhost:8080/realms/demo/protocol/openid-connect/token/introspect"
}
Hard-code the discovery URL, not the endpoints. Every path under it is derived from the realm name and the hostname configuration, and both change between environments.
Now the token request. Note the scope=openid — it matters:
curl -s -X POST http://localhost:8080/realms/demo/protocol/openid-connect/token \
-d client_id=demo-app -d username=alice -d password=s3cret \
-d grant_type=password -d scope=openid > /tmp/t.json
jq 'keys' /tmp/t.json
["access_token","expires_in","id_token","not-before-policy",
"refresh_expires_in","refresh_token","scope","session_state","token_type"]
The first thing that surprises people
Drop scope=openid and run it again. The response no longer has an id_token:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5…",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzUxMiIsInR5…",
"token_type": "Bearer",
"not-before-policy": 0,
"session_state": "CACfLyp3tHqIrNV6PODbn991",
"scope": "email profile"
}
Without openid in the requested scope this is a plain OAuth 2.0 request, not an OpenID
Connect one, and Keycloak has no reason to issue an identity token. If your library reports
"no ID token returned", this is almost always why.