Keycloak SSO Session Lifespans
Revised by
2026_06_12_declarative_keycloak_realm_reconciliation: that ADR restructured the realm-config templates, so thekeycloak-realm.json.j2referenced below was split intobootstrap/+managed/templates and merged intoaihub-realm.{stage}.jsonfor the first-start import (the lifespans now live inbootstrap/realm-settings.json.j2). The session-lifespan decision and mechanism described here (first-start bootstrap seed + the entrypointkcadmdefault-migration) are unchanged.
Context
The Keycloak realm import never set any session or token lifetime values, so every deployment ran on Keycloak's defaults: 30 minutes SSO session idle timeout and 10 hours SSO session max lifespan. The admin UI is a pure SPA OIDC client (oidc-client-ts) whose refresh token is an online token bound to the SSO session — offline_access is not requested. Any overnight pause therefore exceeded the 30-minute idle window, invalidated the refresh token stored in localStorage, and forced users through a full credential login every morning.
Two timers govern an SSO session, whichever fires first ends it:
ssoSessionIdleTimeout— sliding window; resets on every session activity (most importantly each refresh-token grant). Expires sessions that go unused.ssoSessionMaxLifespan— hard ceiling counted from login, regardless of activity. Bounds the total lifetime of any session.
A complicating operational constraint: the realm JSON is imported on first container start only (keycloak-entrypoint.sh deliberately avoids OVERWRITE_EXISTING, see import strategy comments there). Realm-level settings added to the template never reach already-provisioned deployments.
Decision Drivers
- Daily forced re-login is unacceptable UX:
An enterprise platform used as a daily co-worker must survive an overnight pause and a normal weekend (Friday evening → Monday morning, ~2.5 days idle) without re-authentication. - Sessions must still expire:
Unbounded sessions extend the window in which a stolen refresh token or an offboarded user's live session remains usable. Periodic re-authentication re-verifies credentials against the identity provider (e.g. picks up disabled Entra ID accounts). - Existing deployments must converge without manual intervention:
Automatically updated customer environments cannot rely on a first-start-only realm import to pick up lifespan changes, and operators should not have to runkcadmby hand. - Single source of truth:
The values must not drift between the realm template (fresh installs) and any runtime enforcement mechanism.
Decision
SSO session lifespans are set explicitly to 5 days idle (ssoSessionIdleTimeout: 432000) and 30 days max (ssoSessionMaxLifespan: 2592000), defined once in infra/deployment/compose-config.yml under the keycloak: section.
The chosen values balance the drivers: 5 days idle comfortably covers overnight pauses and weekends while expiring sessions abandoned for a full vacation week; 30 days max caps the total lifetime of even continuously used sessions, forcing a credential re-verification at least monthly.
Implementation
compose-config.ymlholds the values; both Jinja2 templates below render from it, so there is exactly one place to change them.keycloak-realm.json.j2includes the lifespans in the realm import — fresh installs are correct from first start.keycloak-entrypoint.sh.j2applies the lifespans viakcadm.sh update realms/aihubin the post-startup block on container start, but only for fields still holding the Keycloak default (30 min idle / 10 h max). Existing deployments converge on their next Keycloak container recreation, without realm re-import and without touching users, clients, or sessions — while values an operator customized in the admin console are left untouched. Each of the two fields is checked independently.
Consequences
Positive
- Users stay logged in across nights and weekends; silent token renewal succeeds instead of redirecting to the login page.
- The persisted active-tenant restore (see related decision) is reached through silent renew rather than a fresh login, so users land directly in their last tenant.
- Lifespan changes are a one-line config edit followed by
make generate-compose; rollout to all deployments is automatic on the next Keycloak container start.
Trade-offs
- A leaked refresh token remains usable for up to 5 days of inactivity (previously 30 minutes). Mitigations: tokens are bound to the Keycloak session and revoked server-side on logout (
useAuth.logout()calls the backchannel logout), and the 30-day ceiling bounds the worst case. - The entrypoint only migrates away from Keycloak defaults; it does not enforce the configured values. A deployment whose lifespans were already migrated (or customized) will not pick up future changes to the platform defaults in
compose-config.yml— rolling out new values to such deployments requires a manualkcadm/admin-console change. - An operator who deliberately sets a lifespan back to the exact Keycloak default (1800 or 36000 seconds) will see it migrated to the platform value on the next container start, since it is indistinguishable from a never-configured realm.
- Sessions already expired before the rollout cannot be revived; users see one final forced login after the change is deployed.
Addendum (2026-08-20): access token lifespan
The original decision scoped itself to the two SSO session timers and left accessTokenLifespan on Keycloak's default of 300 seconds — never an explicit choice, simply the value nobody set. Every environment therefore ran on a 5-minute access token, which forced the admin SPA into a refresh-token grant roughly every 3 minutes (accessTokenExpiringNotificationTimeInSeconds: 120 in packages/web/plugins/oidc-client.ts) and broke any consumer holding a Keycloak JWT without a refresh loop.
accessTokenLifespan is now set explicitly to 1 hour (3600 seconds), defined alongside the session timers in the keycloak: block of infra/deployment/compose-config.yml and rolled out through the same two mechanisms: the bootstrap realm import for fresh installs, and a conditional kcadm default-migration in the entrypoint (guarded on the value still being exactly 300) for existing deployments.
The security trade-off differs from the session timers. A JWT is validated statelessly against JWKS in KeycloakAuthHandler with no per-request revocation check, so the access token lifespan — not the session timer — is the real bound on how long a logged-out, disabled, or role-revoked user keeps API access. One hour is the accepted window; it is also the staleness bound on the roles and tenants claims that drive sysadmin status and tenant membership sync.
Related Decisions
2026_04_07_active_tenant_as_keycloak_user_attribute.md— active tenant persisted in Keycloak, restored after login2025_12_28_keycloak_as_identity_broker.md— Keycloak as sole OIDC provider2026_02_19_parent_app_owns_auth_state_for_iframe_services.md— the post-iframe-logout grace window this lifespan determines
