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.
Implementation map
Section titled “Implementation map”| Module | Role |
|---|---|
cadence.domain.auth.service | AuthService — login, JWT claims (_build_jwt), refresh, logout |
cadence.data.security.session_store | SessionStoreRepository — Redis session:{jti}, refresh rows, OAuth codes |
Summary for stakeholders
Section titled “Summary for stakeholders”- 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.
Why not just use JWTs?
Section titled “Why not just use JWTs?”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.
Redis key layout
Section titled “Redis key layout”SessionStoreRepository manages these keys:
| Key pattern | Type | Content | TTL |
|---|---|---|---|
session:{jti} | String (JSON) | BearerTokenSession payload | Access token TTL (defaults via global_settings, seeded 10800 s) |
user_sessions:{user_id} | Set | Active access jti values for bulk-invalidation | No TTL — entries removed on session delete |
refresh:{refresh_jti} | String (JSON) | RefreshTokenData payload | Refresh token TTL (default 604800 s) |
user_refresh_sessions:{user_id} | Set | Active refresh jti values | No TTL — entries removed on rotation |
oauth_state:{state} | String (JSON) | Provider + PKCE code verifier for social login | Short-lived handoff TTL |
oauth2_code:{code} | String (JSON) | One-time authorization code payload | 300 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}):
| Field | Type | Purpose |
|---|---|---|
refresh_jti | str | Unique refresh token ID |
user_id | str | Token owner |
access_jti | str | The access jti this refresh token was issued alongside |
client_id | str | None | OAuth2 client that issued the token |
created_at / expires_at | str | ISO timestamps |
The JWT itself
Section titled “The JWT itself”AuthService._build_jwt creates a JWT with these claims:
| Claim | Value |
|---|---|
sub | user_id |
jti | UUID7, unique per session |
iat | Issued-at timestamp |
exp | iat + 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.
Request path
Section titled “Request path”-
AuthenticationMiddlewarevalidates the JWT signature withJWTAuth.validate(HMAC HS256, optional RS256 fallback). An invalid signature raisesInvalidTokenError→ 401 before Redis is touched. -
TenantContextMiddlewaredecodes thejtifrom the JWT with the app secret, then callssession_store.get_session(jti). If Redis returns nothing,request.state.sessionstaysNone. -
On any route that calls
require_session(request), aNonesession raisesAuthenticationError→ 401.
Issuing a session
Section titled “Issuing a session”When a user authenticates (password grant, authorization code, etc.), AuthService calls SessionStoreRepository.create_session:
- Generates a new
jti(UUID7). - Resolves current permissions and org memberships via
_session_auth_fields(user_id). - Builds a
BearerTokenSessionwith all fields populated. - Writes it to Redis:
SETEX session:{jti} {ttl} {json}. - Adds
jtito theuser_sessions:{user_id}set. - Calls
_build_jwtto produce the JWT string returned to the caller. - Optionally creates a refresh token row (
create_refresh_token).
Refresh flow
Section titled “Refresh flow”When the client sends a refresh_token grant to POST /oauth2/token:
SessionStoreRepository.get_refresh_token(refresh_jti)— missing or expired → 401._session_auth_fields(user_id)recomputes permissions from the current database state.create_sessionissues a new accessjtiand writes the new session to Redis.rotate_refresh_tokendeletes the old refresh row and creates a new one linked to the new accessjti.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.
Logout
Section titled “Logout”DELETE /api/auth/logout:
- Reads
request.state.token_jti(set byTenantContextMiddlewarewhen the session was loaded). - Calls
AuthService.logout(jti, refresh_jti=...). delete_session(jti)— removessession:{jti}from Redis and removesjtifrom theuser_sessions:{user_id}set.- If the
X-Refresh-Tokenheader is present, also callsdelete_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 sessions — delete_all_user_sessions(user_id) deletes every access and refresh session for a user at once. Used for password resets and security responses.
Limitations
Section titled “Limitations”- Permissions are a snapshot.
global_permissionsandorg_permissionsin 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_sessionreturnsNonefor 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.