| name | session-management |
| description | Model multi-device sessions on the backend with sliding vs absolute expiry, device listing, and remote logout. Use when building the session model or a "Your devices" screen. |
Session Management
Instructions
A session represents one (user, device) pair. Mobile apps run for months; sessions must be long enough to be painless but short enough to contain damage after loss or theft.
1. Session Model
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
device_label TEXT,
created_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ NOT NULL,
absolute_expiry TIMESTAMPTZ NOT NULL,
sliding_expiry TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
ip_last INET,
ua_last TEXT
);
One row per active session family (see token-strategy). Refresh tokens are rows in a child table with session_id FK.
2. Sliding vs Absolute Expiry
Use both, and revoke when either trips.
- Sliding window: extends on each successful refresh. Typical: 30 days from
last_used_at. Lets active users stay signed in forever; idle devices drop off.
- Absolute window: hard ceiling from
created_at. Typical: 90–180 days. Forces periodic re-auth even on active devices.
valid_session <=> now() < MIN(sliding_expiry, absolute_expiry) AND revoked_at IS NULL
Adjust for the app's risk profile. Banking: sliding 7d / absolute 30d. Social: sliding 60d / absolute 365d.
3. Multi-Device Behavior
Every login creates a new session row -- never reuse across devices. A user has N concurrent sessions (one per device_id).
Listing devices:
GET /v1/me/sessions
{
"items": [
{ "id": "sess_01HX7...", "device_label": "iPhone 15", "last_used_at": "2026-04-10T09:12:00Z", "current": true },
{ "id": "sess_01HX6...", "device_label": "Pixel 9", "last_used_at": "2026-03-28T17:02:00Z", "current": false }
]
}
Revoke a single device:
DELETE /v1/me/sessions/{id}
Revoke all except current (security event):
POST /v1/me/sessions/revoke-others
4. Revocation Semantics
Revocation hides behind the two-layer model from token-strategy:
- Mark
revoked_at = now() on the session row. Child refresh tokens inherit via JOIN.
- Publish the
session_id (a.k.a. sid) to a short-lived denylist (Redis set with TTL = access-token max lifetime, e.g., 15 min).
- Resource services check
sid against the denylist before honoring an access token.
After the access-token TTL, the denylist entry expires; refresh attempts will fail independently because the DB row is revoked.
5. Concurrency Caps (Optional)
For high-assurance apps, cap concurrent sessions per user (e.g., 5). On login, if the count is exceeded, revoke the least-recently-used session.
fun enforceCap(userId: String, max: Int = 5) {
val active = sessions.listActive(userId).sortedBy { it.lastUsedAt }
val toRevoke = active.size - (max - 1)
if (toRevoke > 0) active.take(toRevoke).forEach { sessions.revoke(it.id, reason = "CAP") }
}
Client UX: show the evicted device in a "Signed out of X to make room" notification on next login.
6. Security-Sensitive Actions
Password change, 2FA enrollment change, email change, account deletion:
- Revoke all sessions except the one performing the action.
- Force re-auth for the current session within 30 days (reset absolute expiry).
7. Step-Up Auth
For high-risk actions (large transfer, adding a new recipient), require a fresh authentication within N minutes even on a long-lived session. Track last_mfa_at on the session row; enforce in the API layer:
def require_fresh_auth(max_age: timedelta = timedelta(minutes=5)):
def dep(session: Session = Depends(current_session)):
if not session.last_mfa_at or now() - session.last_mfa_at > max_age:
raise HTTPException(401, {"error": {"code": "REAUTH_REQUIRED"}})
return dep
8. Client Consumption
When the app receives REAUTH_REQUIRED or INVALID_SESSION, it navigates to the login screen and drops the refresh token. Do not attempt silent retry.
switch error {
case .reauthRequired:
await router.present(.login(reason: .reauth))
case .invalidSession:
await tokenStore.clear()
await router.replaceRoot(.login(reason: .expired))
}
Checklist