Keycloak refresh tokens and session timeouts explained
Why Keycloak refresh returns 400, how Idle and Max session timeouts differ, and how to single-flight refresh tokens with rotation-safe storage in JavaScript.
This article is published in English.
Sources: Refresh Tokens and Session Timeouts in Keycloak: Why a 400 Isn’t Always a Bug
Keycloak-based apps occasionally see the token endpoint answer 400 Bad Request during refresh. The quick read is that renewal is broken. More often Keycloak is refusing on purpose: the session idled out, hit max age, was revoked, or the refresh token is no longer valid. Those outcomes are policy, not defects.
This guide walks the pieces that matter in production: what access and refresh tokens do, how renewal runs end to end, which two session timers gate a successful refresh, and the frontend patterns—and anti-patterns—around handling the rest.
Why this article exists
When the token endpoint returns 400, teams often file an auth bug. The useful first question is which lifetime or revocation rule fired. Understanding that answer prevents treating a correct denial as an outage.
1. Two tokens, two very different jobs
After login, Keycloak typically issues an access token, a refresh token, and an ID token. Day-to-day work hinges on the first two.
- Access token — Attached to ordinary API calls to prove who is calling. Lifetime is short, often measured in minutes.
- Refresh token — Sent only to Keycloak to mint a new access token. Lifetime is longer, commonly thirty minutes or more.
- Offline token — Optional long-lived access that can outlast a normal SSO session, governed by separate policy.
A practical picture: the access token is a temporary badge shown at every door. The refresh token is the paperwork that replaces the badge; it is never shown to application APIs and must travel only to Keycloak’s token endpoint.
2. The normal flow
Login returns the token set. Afterward the client attaches the access token on each call:
GET /api/products
Authorization: Bearer eyJ...
Access lifetimes stay short—five to fifteen minutes is common. If login happens at 09:00 with a five-minute access token, by 09:05 the next API call returns 401 Unauthorized.
That 401 is not an immediate logout. It is the signal to refresh.
3. The refresh call itself
The client posts to Keycloak’s token endpoint with the refresh token:
POST /realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
client_id=<client-id>
refresh_token=<refresh-token>
A healthy body looks like:
{
"access_token": "new-access-token",
"expires_in": 300,
"refresh_token": "new-refresh-token",
"refresh_expires_in": 1800
}
The client stores the new access token, retries the original request, and the user never sees the hop.
4. Rotation detail: replace the stored refresh credential every time
The response often includes a new refresh token as well. When refresh token rotation is on, the previous refresh token is invalidated as soon as the new one is issued. Reusing the old value fails on the next renewal.
// Correct
saveAccessToken(response.access_token);
if (response.refresh_token) {
saveRefreshToken(response.refresh_token);
}
// Wrong — keeps using the token that rotation just invalidated
const refreshToken = originalRefreshToken;
Persisting the rotated refresh value on each success—not just after login—is what separates a session that renews quietly for hours from one that fails on the second attempt.
5. Idle versus Max: the clocks that gate renewal
Realms ship at least two session clocks, and swapping their meanings causes most “why did auth die” tickets:
- SSO Session Idle — How long the session may sit with no activity before Keycloak ends it. A successful refresh counts as activity and resets this clock.
- SSO Session Max — A hard ceiling on total age measured from the original login. Refresh does not reset it. Nothing does.
In short:
IDLE = how long since the session was last active
MAX = how long since the user originally logged in
With Idle = 30 minutes and Max = 16 hours, frequent refreshes keep the idle clock young all day, yet at sixteen hours from login the session ends even if the user was active a minute earlier. Perfect refresh timing cannot stretch past max; it only preserves life inside that window.
Per-client Idle/Max knobs mirror the realm clocks but only for that client. Whichever bound is stricter applies.
Why a sixteen-hour max shows up in examples: an ERP team with a roughly twelve-hour shift might choose twelve hours of work plus a four-hour buffer so a session survives a full day without mid-task re-login, without lasting forever. The number is a business choice; the important skill is knowing which clock it controls.
Why a thirty-minute idle still surprises people: even with a generous max, a long lunch or meeting can trip idle early. If users should return without re-authenticating, revisit idle as a product/security decision—not as something to paper over only in client code.
6. Reading 400 from the token endpoint without assuming an outage
A failed refresh may look like:
{
"error": "invalid_grant",
"error_description": "Token is not active"
}
That payload can point at several distinct cases:
- refresh credential aged out by itself
- idle policy closed the SSO session
- max age from login was reached
- an end-user or operator explicitly signed the session out
- administrative revocation removed the session
- rotation retired a prior refresh value that a client still holds
- concurrent renewals raced and one reused a credential another call had already replaced
None of those are “the API is down.” Read error and error_description before assuming otherwise.
7. Handling it on the frontend, correctly
A durable pattern:
401 on API request
→ try refresh, exactly once
→ success: save new tokens, retry the original request
→ failure: clear auth state, redirect to login
Do not bounce forever: 401, renew, 400, renew again. One dead session then becomes a flood of token calls and a spinner that never resolves. Treat renewal failure as final—drop credentials, clear auth state, route to login, and halt.
8. Fan-out 401s require a single shared renewal
A dashboard that fires five calls at once with an expired access token receives five 401s. If each path refreshes independently under rotation, the first success invalidates the token the other four still hold—so four renewals fail with 400 invalid_grant even though the session was fine.
Serialize renewal: the first 401 opens a single promise; siblings await that same promise and retry after it settles.
if (tokenExpired) {
if (!refreshInProgress) {
refreshInProgress = refreshToken();
}
try {
const tokens = await refreshInProgress;
updateTokens(tokens);
return retryOriginalRequest();
} finally {
refreshInProgress = null;
}
}
Whether the lock is a hand-rolled promise, an Axios interceptor queue, or an RTK Query baseQuery wrapper, the rule is fixed: one refresh in flight, no matter how many callers wait.
9. Settings that look related but aren’t: Login Timeout and Login Action Timeout
Two more realm knobs are easy to confuse with session lifetime, yet they govern only the login ceremony:
- Login Timeout — How long the whole authentication flow (credentials, MFA, required actions) may remain open.
- Login Action Timeout — How long a single step (for example an OTP screen) may sit unanswered.
Neither extends an already-authenticated session. They matter only while the user is still logging in.
10. Offline sessions — a different tool for a different problem
Realms can also issue offline sessions via scope=offline_access. Those allow new access tokens even after the normal SSO session is gone—aimed at “stay signed in tomorrow and next week,” not at a single workday.
For ordinary business-hours use (a shift plus a buffer), offline access is usually unnecessary. A tuned Session Max is simpler and tighter. Reach for offline only when persistence across days or weeks is the real requirement.
11. Debugging checklist: when refresh comes back 400
Work through this order:
- Read
erroranderror_descriptionon the response. - Compare login time to
Session Maxat realm and client levels. - Compare inactivity to
Session Idleat realm and client levels. - Inspect client overrides under
Clients → <client> → Advanced. - Check whether refresh token rotation is enabled.
- Check whether multiple requests refreshed concurrently.
- Confirm the client stores the newest refresh token after every renewal, not only the first.
- Check whether the session was ended manually by the user or an admin.
12. The mental model to keep
Three layers, with dependency flowing one way:
Access token → used for normal API calls
Refresh token → used to get a new access token
Keycloak session (Idle + Max) → decides whether that refresh is allowed at all
A refresh token is never independent of its session. If the session is gone—idle too long, past max age, revoked, or logged out—no refresh token revives it. That refusal is the system working as designed.
Checklist
- Treat access-token expiry separately from session death—attempt a single renewal before interpreting a 401 as logout.
- Persist the rotated refresh credential on every success, not merely the fresh access JWT.
- Remember the two timers: activity renews idle; wall-clock from login owns max.
- Make renewal failure final—one attempt, then wipe credentials and send the user to sign-in; never retry in a loop.
- Collapse concurrent 401s onto one shared renewal promise instead of five independent calls.
- Keep Login Timeout and Login Action Timeout in the login ceremony bucket; they are not Idle/Max.
- Reserve offline sessions for multi-day stay-signed-in needs, not ordinary shift-length SSO.
- Read the token endpoint error payload first; a 400 is usually a specific policy refusal, not a crash.