How Keycloak token responses work, why each field exists, and how clients manage access and refresh tokens through the full session lifecycle.
What Keycloak Returns After Login
When a client logs in to Keycloak, it does not receive only a JWT string.
It receives a full OAuth 2.0 / OpenID Connect token response that gives the client everything it needs to:
- call APIs now
- know how long the token is valid
- renew the session later without asking the user to log in again immediately
Example Token Response
A typical response for alice logging in via mobile-banking-app looks like this:
{
"access_token": "<jwt>",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "<refresh-token>",
"token_type": "Bearer",
"scope": "email profile",
"session_state": "<uuid>"
}
What Each Field Means
access_token
This is the token used to call protected APIs right now.
In this PoC:
- alice or ops-admin sends it to Kong
- Kong introspects it with Keycloak
- banking-api-service validates it again before returning banking data
Think of it as the short-lived API ticket.
expires_in
This tells the client how many seconds the access_token remains valid.
Example:
- 300 means 5 minutes
Purpose:
- keep access tokens short-lived
- limit risk if an access token is leaked
refresh_token
This is a separate token used to get a new access_token without asking the user to log in again immediately.
Think of it as the session continuation token.
The refresh token is not sent to banking-api-service. It is sent back to Keycloak when the client wants a new access token.
refresh_expires_in
This tells the client how many seconds the refresh_token remains usable.
Example:
- 1800 means 30 minutes
Purpose:
- limit how long the session can be silently extended
- avoid refresh tokens living forever
token_type
Usually Bearer. That means the client should send the access token like this:
Authorization: Bearer <access_token>
scope
This tells the client which scopes were granted.
In this PoC you often see:
- email profile
Scopes express granted capabilities or identity information available in the token.
session_state
Keycloak also returns a session_state field, which is an opaque identifier for the server-side session Keycloak keeps on behalf of the authenticated user.
This is not primarily a client-side concern, but it matters for understanding the full picture (see Access Token, Refresh Token, and Session State below).
Why These Fields Exist: The Security-Usability Tradeoff
If Access Tokens Lived Too Long
Convenient, but unsafe. A stolen token could be used for a long time.
If Access Tokens Lived Too Short Without Refresh
Safe, but painful. Users would have to log in again very frequently.
The Combined Design
The standard design is:
- short-lived access_token
- longer-lived refresh_token
This gives:
- better security for API calls
- better user experience for session continuity
Access Token vs Refresh Token vs Session State
These three concepts are related but distinct.
Access token
The access_token is:
- a short-lived bearer token
- usually self-contained as a JWT
- used to call APIs directly
It carries claims such as sub, preferred_username, realm_access.roles, customer_id, account_ids, and aud. Services like banking-api-service can often validate it locally using JWKS without contacting Keycloak.
Think of it as answering: “can this token present claims to an API right now?”
Refresh token
The refresh_token is:
- used to obtain a new access_token
- more directly tied to the ongoing Keycloak session
- not sent to banking-api-service or any resource API
If the underlying session is gone, expired, or invalidated, the refresh token stops working even if an old access token still exists.
Think of it as answering: “can this client continue the login session and get a new access token?”
Session state
Keycloak maintains server-side session state that is separate from but related to both tokens.
Keycloak tracks three layers of session:
At runtime, Keycloak stores online session state primarily in Infinispan caches. In clustered deployments these caches are distributed across nodes. Offline sessions are persisted in the database.
The critical insight:
- JWT claims travel inside the token
- Live session activity lives server-side in Keycloak
That is why a token can still decode as a valid JWT while introspection returns active: false. For full introspection mechanics see 11 — JWT Signature, Validation & Introspection.
Summary table
| access_token | Call protected APIs | Yes | Short (e.g. 5 min) |
| refresh_token | Get a new access token | No, sent to Keycloak only | Longer (e.g. 30 min) |
| Session state | Server-side session tracking | Not directly | Tied to user/client session TTL |
Relationship diagram
#mermaid-svg-B7csj6QmyWt358e5{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-B7csj6QmyWt358e5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-B7csj6QmyWt358e5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-B7csj6QmyWt358e5 .error-icon{fill:#552222;}#mermaid-svg-B7csj6QmyWt358e5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-B7csj6QmyWt358e5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-B7csj6QmyWt358e5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-B7csj6QmyWt358e5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-B7csj6QmyWt358e5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-B7csj6QmyWt358e5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-B7csj6QmyWt358e5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-B7csj6QmyWt358e5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-B7csj6QmyWt358e5 .marker.cross{stroke:#333333;}#mermaid-svg-B7csj6QmyWt358e5 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-B7csj6QmyWt358e5 p{margin:0;}#mermaid-svg-B7csj6QmyWt358e5 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-B7csj6QmyWt358e5 .cluster-label text{fill:#333;}#mermaid-svg-B7csj6QmyWt358e5 .cluster-label span{color:#333;}#mermaid-svg-B7csj6QmyWt358e5 .cluster-label span p{background-color:transparent;}#mermaid-svg-B7csj6QmyWt358e5 .label text,#mermaid-svg-B7csj6QmyWt358e5 span{fill:#333;color:#333;}#mermaid-svg-B7csj6QmyWt358e5 .node rect,#mermaid-svg-B7csj6QmyWt358e5 .node circle,#mermaid-svg-B7csj6QmyWt358e5 .node ellipse,#mermaid-svg-B7csj6QmyWt358e5 .node polygon,#mermaid-svg-B7csj6QmyWt358e5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-B7csj6QmyWt358e5 .rough-node .label text,#mermaid-svg-B7csj6QmyWt358e5 .node .label text,#mermaid-svg-B7csj6QmyWt358e5 .image-shape .label,#mermaid-svg-B7csj6QmyWt358e5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-B7csj6QmyWt358e5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-B7csj6QmyWt358e5 .rough-node .label,#mermaid-svg-B7csj6QmyWt358e5 .node .label,#mermaid-svg-B7csj6QmyWt358e5 .image-shape .label,#mermaid-svg-B7csj6QmyWt358e5 .icon-shape .label{text-align:center;}#mermaid-svg-B7csj6QmyWt358e5 .node.clickable{cursor:pointer;}#mermaid-svg-B7csj6QmyWt358e5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-B7csj6QmyWt358e5 .arrowheadPath{fill:#333333;}#mermaid-svg-B7csj6QmyWt358e5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-B7csj6QmyWt358e5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-B7csj6QmyWt358e5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B7csj6QmyWt358e5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-B7csj6QmyWt358e5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B7csj6QmyWt358e5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-B7csj6QmyWt358e5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-B7csj6QmyWt358e5 .cluster text{fill:#333;}#mermaid-svg-B7csj6QmyWt358e5 .cluster span{color:#333;}#mermaid-svg-B7csj6QmyWt358e5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-B7csj6QmyWt358e5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-B7csj6QmyWt358e5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-B7csj6QmyWt358e5 .icon-shape,#mermaid-svg-B7csj6QmyWt358e5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B7csj6QmyWt358e5 .icon-shape p,#mermaid-svg-B7csj6QmyWt358e5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-B7csj6QmyWt358e5 .icon-shape .label rect,#mermaid-svg-B7csj6QmyWt358e5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B7csj6QmyWt358e5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-B7csj6QmyWt358e5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-B7csj6QmyWt358e5 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
refresh grant
alice or ops-admin logs in
Keycloak
Server-side session state
access_token
refresh_token
Kong introspection
banking-api-service JWT validation
What Problem Refresh Tokens Solve
Refresh tokens solve a practical problem:
- how can a client keep a user signed in without holding a long-lived access token?
Without refresh tokens, the client would need to ask alice to log in again every time the access token expired. That would be painful for:
- mobile apps
- SPAs
- dashboards
- long-lived user sessions
Refresh tokens allow the client to quietly ask Keycloak for a new access token and continue the session until the refresh token itself expires.
Session Renewal Flow
banking-api-service
Keycloak
Client (alice session)
banking-api-service
Keycloak
Client (alice session)
#mermaid-svg-QIbz6miwEInNIcIF{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QIbz6miwEInNIcIF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QIbz6miwEInNIcIF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QIbz6miwEInNIcIF .error-icon{fill:#552222;}#mermaid-svg-QIbz6miwEInNIcIF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QIbz6miwEInNIcIF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QIbz6miwEInNIcIF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QIbz6miwEInNIcIF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QIbz6miwEInNIcIF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QIbz6miwEInNIcIF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QIbz6miwEInNIcIF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QIbz6miwEInNIcIF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QIbz6miwEInNIcIF .marker.cross{stroke:#333333;}#mermaid-svg-QIbz6miwEInNIcIF svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QIbz6miwEInNIcIF p{margin:0;}#mermaid-svg-QIbz6miwEInNIcIF .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QIbz6miwEInNIcIF text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QIbz6miwEInNIcIF .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QIbz6miwEInNIcIF .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QIbz6miwEInNIcIF .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QIbz6miwEInNIcIF .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QIbz6miwEInNIcIF #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QIbz6miwEInNIcIF .sequenceNumber{fill:white;}#mermaid-svg-QIbz6miwEInNIcIF #sequencenumber{fill:#333;}#mermaid-svg-QIbz6miwEInNIcIF #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QIbz6miwEInNIcIF .messageText{fill:#333;stroke:none;}#mermaid-svg-QIbz6miwEInNIcIF .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QIbz6miwEInNIcIF .labelText,#mermaid-svg-QIbz6miwEInNIcIF .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QIbz6miwEInNIcIF .loopText,#mermaid-svg-QIbz6miwEInNIcIF .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QIbz6miwEInNIcIF .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QIbz6miwEInNIcIF .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QIbz6miwEInNIcIF .noteText,#mermaid-svg-QIbz6miwEInNIcIF .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QIbz6miwEInNIcIF .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QIbz6miwEInNIcIF .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QIbz6miwEInNIcIF .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QIbz6miwEInNIcIF .actorPopupMenu{position:absolute;}#mermaid-svg-QIbz6miwEInNIcIF .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QIbz6miwEInNIcIF .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QIbz6miwEInNIcIF .actor-man circle,#mermaid-svg-QIbz6miwEInNIcIF line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QIbz6miwEInNIcIF :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Time passes, access_token nears expiry
Login (username + password)
access_token + refresh_token + session_state
API call with access_token
Refresh request with refresh_token
new access_token + new refresh_token
API call with new access_token
How Automatic Renewal Works At The Client Side
The basic client logic is:
- access_token
- refresh_token
- access-token expiry time
- refresh-token expiry time
Client-Side Pseudocode
login() {
tokenResponse = requestToken(username, password)
store.accessToken = tokenResponse.access_token
store.refreshToken = tokenResponse.refresh_token
store.accessTokenExpiresAt = now() + tokenResponse.expires_in
store.refreshTokenExpiresAt = now() + tokenResponse.refresh_expires_in
}
getValidAccessToken() {
if now() < store.accessTokenExpiresAt – 30 seconds {
return store.accessToken
}
if now() >= store.refreshTokenExpiresAt {
redirectToLogin()
return
}
refreshed = refreshSession(store.refreshToken)
store.accessToken = refreshed.access_token
store.refreshToken = refreshed.refresh_token
store.accessTokenExpiresAt = now() + refreshed.expires_in
store.refreshTokenExpiresAt = now() + refreshed.refresh_expires_in
return store.accessToken
}
callApi() {
token = getValidAccessToken()
send Authorization: Bearer <token>
}
Why Refresh Slightly Before Expiry
Notice the line:
- now() < accessTokenExpiresAt – 30 seconds
Clients often renew a little early instead of waiting for exact expiry. That avoids problems such as:
- network latency
- clock skew
- token expiring while a request is in flight
Refresh Request Example
When a client refreshes, it calls the Keycloak token endpoint with a different grant type:
POST /realms/banking-poc/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
client_id=mobile-banking-app
refresh_token=<refresh_token>
Keycloak responds with a fresh token response including:
- new access_token
- new refresh_token
- new expires_in
- new refresh_expires_in
Failure Cases
Case 1: Access token expired, refresh token still valid
- client calls the refresh endpoint
- Keycloak issues new tokens
- alice stays logged in transparently
Case 2: Refresh token expired or invalid
- Keycloak rejects the refresh request
- the client can no longer renew the session
- alice must log in again
Case 3: Session invalidated server-side
Because Keycloak maintains server-side session state, the refresh token can be rejected even before its refresh_expires_in timestamp is reached, for example if:
- alice logged out from another device
- the user was disabled in Keycloak
- the client was disabled
- a realm or client invalidation event occurred
In this case Keycloak will return an error on the refresh attempt, and the client must treat it the same as an expired refresh token.
How This Relates To This PoC
In this repo:
- scripts/demo.sh logs in as alice or ops-admin and extracts only access_token
- it ignores the refresh_token
Why?
- the script is short-lived
- it only needs to demonstrate a small number of API calls
- it does not behave like a real mobile or web client session manager
A real mobile banking app would:
- store the refresh token safely (e.g. in secure platform storage)
- monitor token expiry proactively
- refresh tokens automatically in the background
Security Considerations
Refresh tokens are sensitive. In many ways they are more sensitive than short-lived access tokens because they can be exchanged for new access tokens repeatedly.
Important rules:
- do not expose refresh tokens unnecessarily
- do not send refresh tokens to banking-api-service or any resource API
- send refresh tokens only to the Keycloak token endpoint
- store them carefully based on client type
For example:
- mobile apps often store them in secure platform storage
- browser apps need more careful design because browser storage has different risk profiles
Mental Model
That is why Keycloak returns these fields and what problem they solve.
← Prev: 12 — JWKS Deep Dive · Next: 14 — Request & Response Details →
📚 返回专栏目录





