Inside Keycloak's Offline Tokens: How Long-Lived Sessions Actually Work
You're building a CLI tool. The user logs in once, your tool grabs a token, and then a few hours later everything breaks. The refresh token expired with the SSO session, and now your background job is asking the user to open a browser at 3 AM.
This is exactly the problem offline_access was designed to solve. Keycloak's offline tokens give you authentication that survives logout, browser closes, and even server restarts. The tokens don't live forever just because they're "offline" though, and the rules for revocation aren't what you'd guess.
Let's pull the cover off.
What an offline token really is
An offline token is a special refresh token. That's it. There's no magic access token, no separate API. When you request the offline_access scope during login, Keycloak issues a refresh token with one bit flipped in the JWT header: "typ": "Offline" instead of "typ": "Refresh".
That single flag changes everything downstream. The token survives SSO session expiration, browser cookie deletion, and Keycloak restarts. You can use it weeks or months later to grab fresh access tokens without redirecting the user back to a browser.
Here's how you ask for one:
curl -X POST "https://kc.example.com/realms/myrealm/protocol/openid-connect/token" \
-d "client_id=my-cli" \
-d "grant_type=password" \
-d "username=alice" \
-d "password=hunter2" \
-d "scope=openid offline_access"
The response looks like a normal token response, but decode the refresh_token and you'll see the Offline type. Store this token somewhere safe. It's effectively a long-lived credential.
The server-side session you didn't see
The token itself doesn't carry the authority. Keycloak stores a corresponding "offline session" record in its database, in a table called offline_user_session, and the token is just a handle that points to that row.
This matters for two reasons. First, the token can't be validated standalone, Keycloak has to check its database. Second, when you revoke an offline session, the token dies instantly even if it's already in someone's hands. You don't have to wait for an expiration timer.
The session record holds the user ID, client ID, scopes, and timestamps. Every time you use the refresh token, Keycloak updates last_session_refresh on that row.
How long "long-lived" really means
Offline tokens are not immortal. Keycloak has two settings that control their lifetime, both in Realm Settings → Tokens:
| Setting | Default | What it does |
|---|---|---|
Offline Session Idle |
30 days | Token dies if not refreshed within this window |
Offline Session Max Lifespan |
Disabled | Absolute cap on session age, even with active refreshes |
The idle timeout is the one that surprises people. If your CLI tool sits dormant for 31 days, the token is gone. The user has to log in again. This is a common pitfall for batch jobs that run monthly.
Each time you use the refresh token to get a new access token, the idle timer resets. So a daily cron job stays alive forever (assuming max lifespan is disabled, which it is by default).
The refresh flow in practice
Using an offline token looks identical to any other refresh:
curl -X POST "https://kc.example.com/realms/myrealm/protocol/openid-connect/token" \
-d "client_id=my-cli" \
-d "grant_type=refresh_token" \
-d "refresh_token=$OFFLINE_TOKEN"
You get a fresh access token and (depending on your config) a new refresh token. If Revoke Refresh Token is enabled on the realm, the old offline token is invalidated and you must store the new one. If it's disabled, the same offline token keeps working.
This is where CLI tools get into trouble. With rotation on, two parallel processes refreshing the same token will fight: one wins, the other gets invalid_grant and has to re-auth.
Revoking offline tokens properly
A logout endpoint won't kill an offline session. That's the design. To actually revoke one, you have three options:
# Option 1: Revoke via /revoke endpoint
curl -X POST "https://kc.example.com/realms/myrealm/protocol/openid-connect/revoke" \
-d "client_id=my-cli" \
-d "token=$OFFLINE_TOKEN" \
-d "token_type_hint=refresh_token"
# Option 2: Admin API, kill all offline sessions for a user
curl -X POST "https://kc.example.com/admin/realms/myrealm/users/$USER_ID/logout" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Option 3 is the Account Console: users can list and revoke their own offline sessions under "Applications." This is the cleanest UX for end users who want to "log out of that script I ran last month."
Where offline tokens actually shine
The pattern works best when you need authentication without an interactive browser:
- CLI tools like
kubectl, terraform providers, custom internal scripts - Mobile apps that should "stay logged in" without showing a login screen on every cold start
- Background workers that pull from queues and call your APIs as a specific user
- IoT devices that authenticate once during provisioning
For server-to-server communication where no user is involved, use service accounts with grant_type=client_credentials instead. That's a different flow with its own lifecycle.
Troubleshooting
invalid_grant: Offline user session not found
The offline session was revoked, hit its idle timeout, or the user changed their password (which invalidates all sessions by default). The token has to be re-issued via a fresh login.
Client is not allowed to use offline_access
In your client settings, check that Consent Required doesn't block the scope, and that offline_access is in the client's optional or default scopes. Open the client, go to Client Scopes, and confirm offline_access is assigned.
Tokens stop refreshing after Keycloak upgrade
Some upgrades migrate offline session tables. If you skipped a major version, the schema migration may have failed silently. Check the Keycloak server logs for migration errors and verify rows exist in offline_user_session.
Refresh works locally but fails in production
The realm Revoke Refresh Token setting is different between environments. With rotation on, you can only use a refresh token once. Make sure your token storage is updated atomically after every refresh.
Wrapping up
Offline tokens give you exactly what their name suggests: authentication that works when the user isn't there. The catch is that "offline" doesn't mean "forever." There's a 30-day idle clock by default, server-side session state that has to stay reachable, and a revocation model that demands the right endpoint.
Get those right and you have one of the cleanest patterns in OIDC for keeping a CLI, a mobile app, or a background worker authenticated indefinitely.
If you want to spin up a Keycloak instance without the operational drudgery (TLS, backups, upgrades), you can deploy Keycloak on Elestio in a few clicks and get straight to building.
Thanks for reading ❤️
See you in the next one 👋