欢迎光临
我们一直在努力

Authn & Authz for Microservice: 13 — Access & Refresh Token Lifecycle

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:

  • Authentication session (short-lived) — temporary state during the login flow; removed after login completes or expires
  • User session — represents the authenticated user in a realm; tracks start time, idle/expiry state, and logout status
  • Client session (per client app) — attached to a user session for each client such as mobile-banking-app; tracks client-specific participation in that login 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

    Token / conceptMain purposeSent to banking-api-service?Lifetime
    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:

  • Log in once
  • Store:
    • access_token
    • refresh_token
    • access-token expiry time
    • refresh-token expiry time
  • Before the access token expires, call Keycloak with the refresh token
  • Replace stored tokens with the new response
  • If refresh fails, redirect the user back to login
  • 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

  • access_token is for calling APIs now
  • expires_in tells you how long that token lasts
  • refresh_token is for getting a new access token without re-login
  • refresh_expires_in tells you how long session renewal remains possible
  • session_state is the server-side anchor — Keycloak can invalidate a session independently of the token timestamps
  • When refresh expires or the session is invalidated, the user must log in again
  • That is why Keycloak returns these fields and what problem they solve.


    ← Prev: 12 — JWKS Deep Dive · Next: 14 — Request & Response Details →


    📚 返回专栏目录

    赞(0)
    未经允许不得转载:171主机测评 » Authn & Authz for Microservice: 13 — Access & Refresh Token Lifecycle
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址