Sessions
Refresh tokens, rotation, atomic session limits, and the four reasons a session can end.
A session is the lifetime of a refresh token. Every successful /login
opens one; every /logout closes one; rotations replace one without ever
leaving a gap. Febasi Auth tracks every session it has ever opened and
records why each one ended.
Anatomy of a refresh token
The refresh token returned to the client is a UUID — the primary key of
the session row in the refresh_tokens table. The lookup on /refresh is
keyed directly by this UUID. The row also captures:
| Field | Purpose |
|---|---|
id | UUID. The value the client receives and sends back on /refresh. |
user_id | Owner of the session. |
tenant_id | Tenant scope. |
token_hash | Reserved 32-byte random value. Currently not used for verification. |
expires_at | Default 7 days; configurable per tenant. |
revoked_at | Timestamp when the session was closed. NULL when active. |
revoke_reason | One of USER_LOGOUT, TOKEN_ROTATION, AUTOMATIC_SESSION_LIMIT, MANUAL_REVOKE. |
ip_address | The client's IP at session creation. Useful for audit. |
user_agent | The client's user-agent at session creation. |
Planned breaking change
A future release will split the refresh-token credential into an opaque identifier and a separate verifier hashed at rest, so that exposure of the database alone does not yield usable tokens. The wire format will change (longer, non-UUID string), and clients will need to store it verbatim without parsing. The change will be announced ahead of time with a migration window.
Session limits — why they're atomic
Each tenant configures a max-concurrent-sessions value. When a user logs
in and is already at the limit, Febasi Auth performs the following inside a
single transaction with SELECT FOR UPDATE:
Lock the user's active sessions
SELECT * FROM refresh_tokens
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
FOR UPDATECount + decide
If the count is below the tenant limit, do nothing. If it's at or above, collect the oldest session(s) so the new one fits.
Revoke the oldest, mint the new
UPDATE refresh_tokens
SET revoked_at = NOW(), revoke_reason = 'AUTOMATIC_SESSION_LIMIT'
WHERE id IN (oldest_ids);
INSERT INTO refresh_tokens (...) VALUES (...);This happens in the same transaction, so there is never a moment where the user has more than the configured limit of active sessions.
If the limit is configured strict enough that the user is rejected instead of
being downgraded, Febasi Auth returns SESSION_LIMIT_EXCEEDED (HTTP 429)
with the current and max counts in the payload.
Token rotation
Every call to /refresh rotates the token:
old refresh token → marked revoked (reason: TOKEN_ROTATION)
new refresh token → inserted, returned to client
new access token → generated from the freshest user stateThe client must always replace its stored refresh token with the latest one. Reusing a revoked token is never accepted.
Why this matters
- Stolen-token blast radius. A leaked refresh token is invalid the moment the legitimate client refreshes — token rotation cuts the attacker off.
- Real-time logout. Revocation is immediate. The next refresh fails; outstanding access tokens still work until they expire (max 15 minutes by default), which is the cost of stateless JWTs.
- Forensics. Every
revoke_reasonandrevoked_atis preserved for audit.
Revoking sessions
Sessions can be closed in four ways. Each is recorded in the row's
revoke_reason and emitted to the audit log.
| Action | Reason |
|---|---|
POST /api/v1/logout with the refresh token | USER_LOGOUT |
Successful /refresh (rotates the old one out) | TOKEN_ROTATION |
| New login at the session limit (oldest evicted) | AUTOMATIC_SESSION_LIMIT |
| Admin revocation through the token-management API | MANUAL_REVOKE |
For admin revocation — listing active tokens, killing a single session, logging a user out of all devices, or invalidating every session in a tenant — see Session management.
Configuration knobs
These live under auth_config on the tenant.
| Path | Default | What it controls |
|---|---|---|
tokenConfig.accessTokenExpiration | 900 | Access-token lifetime in seconds. |
tokenConfig.refreshTokenExpiration | 7 | Refresh-token lifetime in days. |
sessionLimits.maxConcurrentSessions | 5 | Active sessions allowed per user. |
sessionLimits.enforceLogout | true | At the limit: true evicts the oldest; false rejects login with SESSION_LIMIT_EXCEEDED (HTTP 429). |
sessionTimeout | 30 | Inactivity timeout in minutes. |
Update via PATCH /api/v1/tenants/{id}/config. Existing sessions are not
retroactively shortened — the new values apply to sessions created after
the change.