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. The Auth API 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 Auth API stores only its SHA-256 hash (not bcrypt — these tokens are already high-entropy). The row also captures:
| Field | Purpose |
|---|---|
id | UUID. Same value as the token returned to the client. |
user_id | Owner of the session. |
tenant_id | Tenant scope. |
token_hash | SHA-256 hash 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. |
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, the Auth API 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, the Auth API 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.
How to revoke a session manually
There is no direct "revoke a single refresh token by id" endpoint exposed yet — closing a session goes through one of:
POST /api/v1/logoutwith the refresh token (reason:USER_LOGOUT).- A new login that bumps the oldest one (reason:
AUTOMATIC_SESSION_LIMIT). - An admin operation against a tenant via internal tooling
(reason:
MANUAL_REVOKE). This is audited and exposed to consumers via the audit log.
Configuration knobs
These are stored in the tenant's auth_config:
| Knob | Default | What it controls |
|---|---|---|
accessTokenExpiresIn | 15m | JWT exp window for access tokens. |
refreshTokenExpiresIn | 7d | Lifetime of refresh tokens. |
maxConcurrentSessions | 5 | Sessions allowed per user before rotation kicks in. |
Update them via PATCH /api/v1/tenants/{id}/config. Existing sessions are
not retroactively shortened — the new value applies to sessions created
after the change.