Skip to content

JWT sessions and Redis

BearerTokenSession shape, Redis keys, access/refresh lifecycle, logout and refresh.

Intended audience: Stakeholders, Business analysts, Solution architects, Developers, Testers

Learning outcomes by role

Stakeholders

  • Explain why JWTs alone are insufficient for revocation and why Redis outages affect interactive auth.

Business analysts

  • Document session invalidation scenarios (logout, password change) for user-facing acceptance criteria.

Solution architects

  • Map Redis key layout and TTL strategy to HA Redis and backup policies.

Developers

  • Trace BearerTokenSession, SessionStoreRepository, and AuthService for access and refresh flows.

Testers

  • Predict 401 when Redis rows are deleted while JWTs still verify cryptographically.

A JWT alone is not enough to access Cadence. After a successful login, the server issues a short-lived JWT but also writes the authoritative session — permissions, org memberships, metadata — to Redis, keyed by the JWT’s jti claim. Every subsequent request looks up that Redis row. When it is gone, the request returns 401, even if the JWT signature is still cryptographically valid. This is what makes logout instant.

ModuleRole
cadence.domain.auth.serviceAuthService — login, JWT claims (_build_jwt), refresh, logout
cadence.data.security.session_storeSessionStoreRepository — Redis session:{jti}, refresh rows, OAuth codes
  • Revocation is immediate. Calling logout deletes the session:{jti} row. All subsequent requests using that JWT are rejected before the token itself expires.
  • Redis availability matters. Interactive sessions live in Redis. If Redis is unavailable, all authenticated requests fail with 401 until Redis recovers and users re-authenticate.

Standard stateless JWTs cannot be revoked without waiting for them to expire. Cadence trades some statelessness for the ability to invalidate sessions instantly — on logout, on admin action, or during a security incident. The JWT becomes a lookup key, not the session itself.

SessionStoreRepository manages these keys:

Key patternTypeContentTTL
session:{jti}String (JSON)BearerTokenSession payloadAccess token TTL (defaults via global_settings, seeded 10800 s)
user_sessions:{user_id}SetActive access jti values for bulk-invalidationNo TTL — entries removed on session delete
refresh:{refresh_jti}String (JSON)RefreshTokenData payloadRefresh token TTL (default 604800 s)
user_refresh_sessions:{user_id}SetActive refresh jti valuesNo TTL — entries removed on rotation
oauth_state:{state}String (JSON)Provider + PKCE code verifier for social loginShort-lived handoff TTL
oauth2_code:{code}String (JSON)One-time authorization code payload300 s — consumed on exchange

Token TTLs are read from global_settings at runtime (access_token_ttl_seconds, refresh_token_ttl_seconds) and bootstrapped from AppSettings (10800 s / 604800 s) on first startup if missing.

RefreshTokenData fields (stored in refresh:{refresh_jti}):

FieldTypePurpose
refresh_jtistrUnique refresh token ID
user_idstrToken owner
access_jtistrThe access jti this refresh token was issued alongside
client_idstr | NoneOAuth2 client that issued the token
created_at / expires_atstrISO timestamps

AuthService._build_jwt creates a JWT with these claims:

ClaimValue
subuser_id
jtiUUID7, unique per session
iatIssued-at timestamp
expiat + access_token_ttl_seconds

The jti claim is the only thing TenantContextMiddleware reads from the JWT. It uses that value to call session_store.get_session(jti) in Redis. Everything else (permissions, org IDs) comes from Redis, not the token.

  1. AuthenticationMiddleware validates the JWT signature with JWTAuth.validate (HMAC HS256, optional RS256 fallback). An invalid signature raises InvalidTokenError401 before Redis is touched.

  2. TenantContextMiddleware decodes the jti from the JWT with the app secret, then calls session_store.get_session(jti). If Redis returns nothing, request.state.session stays None.

  3. On any route that calls require_session(request), a None session raises AuthenticationError401.

When a user authenticates (password grant, authorization code, etc.), AuthService calls SessionStoreRepository.create_session:

  1. Generates a new jti (UUID7).
  2. Resolves current permissions and org memberships via _session_auth_fields(user_id).
  3. Builds a BearerTokenSession with all fields populated.
  4. Writes it to Redis: SETEX session:{jti} {ttl} {json}.
  5. Adds jti to the user_sessions:{user_id} set.
  6. Calls _build_jwt to produce the JWT string returned to the caller.
  7. Optionally creates a refresh token row (create_refresh_token).

When the client sends a refresh_token grant to POST /oauth2/token:

  1. SessionStoreRepository.get_refresh_token(refresh_jti) — missing or expired → 401.
  2. _session_auth_fields(user_id) recomputes permissions from the current database state.
  3. create_session issues a new access jti and writes the new session to Redis.
  4. rotate_refresh_token deletes the old refresh row and creates a new one linked to the new access jti.
  5. delete_session(old_access_jti) removes the previous access session so the old JWT stops working immediately.

The key property: the old access token and refresh token are both invalidated atomically at rotation time.

DELETE /api/auth/logout:

  • Reads request.state.token_jti (set by TenantContextMiddleware when the session was loaded).
  • Calls AuthService.logout(jti, refresh_jti=...).
  • delete_session(jti) — removes session:{jti} from Redis and removes jti from the user_sessions:{user_id} set.
  • If the X-Refresh-Token header is present, also calls delete_refresh_token(refresh_jti).

After this, any request using the same JWT returns 401. The JWT may still pass signature validation, but the Redis row is gone.

Force-logout all sessionsdelete_all_user_sessions(user_id) deletes every access and refresh session for a user at once. Used for password resets and security responses.

  • Permissions are a snapshot. global_permissions and org_permissions in the session reflect the database state at login or last refresh. Role changes do not propagate until the user refreshes their token.
  • Redis unavailable → 401 for everyone. Without Redis, get_session returns None for all requests. There is no fallback; plan for Redis HA accordingly.
  • API keys use a different path. API key sessions are built in memory from the database on each request — no session:{jti} row exists. See API keys.