Token Types
Three tokens, three purposes
Section titled “Three tokens, three purposes”Keycloak issues three kinds of tokens during an OIDC flow. Each has a distinct audience and a distinct purpose. Mixing them up is one of the most common security mistakes in OAuth2 integrations.
Access token
Section titled “Access token”The access token authorises API calls. It is a short-lived JWT that your backend APIs validate on every request. When a client makes an API call, it attaches the access token as a Bearer token in the Authorization header:
Authorization: Bearer <access_token>Key characteristics:
- Short-lived by default (5 minutes in Keycloak).
- Contains claims about the user and their granted permissions (roles, scopes).
- Intended for your APIs — not for the client application itself.
- Should be validated on every request using the realm’s public key (via JWKS).
ID token
Section titled “ID token”The ID token tells the client application who the user is. It is consumed by the client — the frontend or application — to display user information (name, email, profile picture). It is an OIDC-specific token and is only issued when the openid scope is requested.
Key characteristics:
- Contains identity claims:
sub,name,email,preferred_username, etc. - Intended for the client application only — do NOT send it to your API.
- Should not be used for authorisation decisions.
Refresh token
Section titled “Refresh token”The refresh token is longer-lived. Its sole purpose is to let the client obtain new access tokens silently — without asking the user to log in again — as long as the user’s session is still active.
Key characteristics:
- Sent only to the Keycloak token endpoint (
/protocol/openid-connect/token). - Never sent to your API.
- Invalidated when the user logs out or when the SSO session expires.
- If the
offline_accessscope is requested, Keycloak issues an offline refresh token that persists beyond the normal session.
Decoded access token example
Section titled “Decoded access token example”Here is what a typical Keycloak access token looks like after decoding:
{ "header": { "alg": "RS256", "typ": "JWT", "kid": "abc123" }, "payload": { "exp": 1719878400, "iat": 1719874800, "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "iss": "https://auth.example.com/realms/my-app", "aud": "account", "sub": "user-uuid-here", "typ": "Bearer", "azp": "my-app-frontend", "scope": "openid profile email roles", "realm_access": { "roles": ["offline_access", "uma_authorization", "user"] }, "resource_access": { "my-app-frontend": { "roles": ["app-user"] } }, "email_verified": true, "name": "Alice Example", "preferred_username": "alice", }}{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "abc123"
},
"payload": {
"exp": 1719878400,
"iat": 1719874800,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"iss": "https://auth.example.com/realms/my-app",
"aud": "account",
"sub": "user-uuid-here",
"typ": "Bearer",
"azp": "my-app-frontend",
"scope": "openid profile email roles",
"realm_access": {
"roles": ["offline_access", "uma_authorization", "user"]
},
"resource_access": {
"my-app-frontend": {
"roles": ["app-user"]
}
},
"email_verified": true,
"name": "Alice Example",
"preferred_username": "alice",
"email": "[email protected]"
}
}Key claims explained
Section titled “Key claims explained”| Claim | Meaning |
|---|---|
sub | Subject — the unique identifier of the user (a UUID). Never changes, even if the username or email changes. |
iss | Issuer — the URL of the Keycloak realm that issued the token. Your API must verify this matches the expected realm. |
aud | Audience — which party the token is intended for. Your API should verify its own client ID appears here. |
exp | Expiry — a Unix timestamp after which the token must be rejected. |
azp | Authorised party — the client ID of the application that requested the token. |
scope | Space-separated list of granted scopes. Reflects which client scopes were included. |
realm_access | An object containing a roles array of the user’s realm-level roles. |
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
Request an ID token alongside the access token (scope=openid) | The client knows the user’s identity immediately, with no extra call to the userinfo endpoint | A larger response, and one more token type to handle correctly — it must never be sent to the backend |
| Use the refresh token to obtain new access tokens | The user never has to log in again, even though the access token itself expires quickly | The refresh token must be stored securely, since it is longer-lived and more privileged than the access token |
| A public client (SPA) uses PKCE instead of a client secret | No secret embedded in frontend code that anyone can inspect | Relies on a strictly configured redirect URI to prevent the authorization code from being stolen during the redirect |
Common mistakes
Section titled “Common mistakes”- Sending the ID token to a backend API — the ID token is meant for the client to read and display user info, not for an API to validate. Always send the access token as the Bearer token instead.
- Storing a token (especially the refresh token) in localStorage — any JavaScript running on the page can read localStorage, so a single XSS vulnerability hands the token straight to an attacker. Keep it in memory or an httpOnly cookie instead.
- Using the access token as a long-lived session identifier — an access token expires and changes value on its own schedule; don’t tie logic that needs to persist to the raw token value. Tie it to the
subclaim or a server-side session instead.
Real-world example
The Auth0 SPA SDK / Google Identity Services — keep access and refresh tokens only in the JavaScript runtime’s memory (never localStorage), and restore the session on page reload through silent refresh or refresh-token rotation.
Mobile banking apps — clearly separate ID-token use (showing the user’s name/profile picture in the app only) from every call to the core banking API, which attaches only the access token via the
Authorization: Bearerheader.