Sessions & refresh
A session in IdentSphere is a row in user_sessions plus a pair of cookies on
the client.
Tokens
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access token | 15 min default | identsphere_at cookie + Authorization header | Authenticates every request. JWT with sub, org, email, aal, mfa_verified_at claims. |
| Refresh token | 30 days default | identsphere_rt cookie | Exchanged at /refresh for a new access token. Opaque; SHA-256 hashed in DB. |
| CSRF token | matches access TTL | identsphere_csrf cookie (not HttpOnly) | Double-submit pattern. |
Rotation
Refresh tokens rotate on every successful /refresh: the presented token is
marked rotated + revoked and a brand-new refresh token is issued in its place.
Rotation is always on — there is no non-rotating mode.
Reuse → family revocation (active)
Replaying an already-rotated refresh token is treated as a token-theft signal.
IdentSphere revokes the entire session family (every token descended from
that login) and emits an auth.refresh.reuse_detected audit event — the user is
logged out everywhere and must sign in again. This is a deliberate tripwire, but
it means a careless client can trip it on itself.
What your refresh client MUST do
A client that calls /v1/auth/refresh directly must do two things, or it will
trip the tripwire and log its own users out:
- Persist the new refresh token on every refresh. Each
/refreshreturns a fresh token; store it and discard the old one — never keep an old token as a fallback. Cookie clients get this for free (the browser/native cookie jar overwrites it). Bearer / native clients must readrefresh_tokenfrom the response body — present only in body mode (you sent the token in the request body, not a cookie) — and overwrite their stored copy. - Single-flight concurrent refreshes. If two requests
401at once and both call/refreshwith the same token, the second is a replay → family revoked. Share one in-flight refresh across all callers (one promise/mutex) and have the rest await it.
Both are handled for you by @identsphere/core (single-flight
refresh-and-retry + automatic rotated-token persistence). If you hand-roll the
refresh loop, you must implement both yourself.
Revocation
Revocation is soft: the row's revoked flag is set to true. The
session-cache stores the revocation signal under
IdentSphere:session:revoked:{session_family_id} so the auth middleware sees
it on the next request without re-querying.
Revoke a session:
| Action | Endpoint |
|---|---|
| Sign out current | POST /v1/auth/logout |
| Sign out one specific | DELETE /v1/auth/sessions/:id |
| Sign out everywhere | DELETE /v1/auth/sessions |
| Sign out everywhere except current (e.g. after password change) | Use sign_out_other_sessions: true on POST /v1/users/me/password |
Revocation feed (for stateless relying parties)
The session-cache above makes revocation instant for IdentSphere's own middleware. But a stateless relying party — a separate service that verifies access tokens locally against JWKS with no per-request hop — wouldn't see a revocation until the access token expires (up to one access lifetime, default 15 min). To close that gap, IdentSphere publishes a revocation feed:
GET /.well-known/identsphere-revocations.json
Cache-Control: public, max-age=5
{
"revoked_sids": ["<session_family_id>", …],
"window_secs": 900,
"ttl": 5,
"as_of": 1748448000
}
It lists the session family IDs (the token's sid claim) revoked within the
last access lifetime. The contract for an RP:
- Verify the access token normally (JWKS,
iss/exp/aud). - Fetch the feed (cache it for
ttlseconds — 5s) and reject the token if itssidis inrevoked_sids.
Turnkey: @identsphere/verify
does both for you — pass revocation: true and a revoked session is honored
within ~5s, with soft-fail-open on feed outages. Other languages follow the
two-step contract above.
Because the window is exactly one access lifetime, the list is self-pruning
and small: once that long has passed since revocation, every access token
bearing that sid has already expired, so the entry drops off. With a 5s client
cache this yields ≤5s effective revocation while staying essentially
stateless (one cheap, cacheable fetch every 5s, not a per-request call). The
feed is unauthenticated by design — it contains only opaque session-family
UUIDs, exactly as JWKS exposes only public keys. Discoverable via the
identsphere_revocation_list_uri field in /.well-known/openid-configuration.
Don't need ≤5s? Lower
IDENTSPHERE_ACCESS_EXPIRY_SECSinstead — revocation lag is bounded by the access TTL with zero extra wiring.
Listing
GET /v1/auth/sessions returns active sessions for the caller, marking
is_current: true for the one signing the request.
[
{
"id": "...",
"user_agent": "Mozilla/5.0 ...",
"ip_address": "203.0.113.42",
"last_active_at": "...",
"is_current": true,
"created_at": "..."
}
]
Session families
session_family_id groups related sessions. A single session family
covers a series of /refresh rotations — same browser, same login,
many access tokens over time. Revoking by family revokes the whole chain.
This is the unit of MFA step-up assertions: the step-up TTL is keyed per family.
Last-active tracking
The auth middleware bumps last_active_at on every authenticated request
(subject to a short debounce window to avoid one UPDATE per request in
hot paths).
DPoP (planned)
The schema already has dpop_thumbprint reserved. DPoP-bound sessions —
tokens that only validate when accompanied by a fresh per-request signature
from the binding keypair — are planned for a future release.
Idle vs absolute timeout
The SDK enforces only ABSOLUTE timeouts (access_expiry_secs,
refresh_expiry_secs). It doesn't enforce idle timeouts. If you want one,
implement it client-side or via a middleware that revokes sessions whose
last_active_at is older than your idle threshold.
CORS + cookies
Cookie auth across origins requires:
- Backend sends
Set-CookiewithSameSite=NoneandSecure(toggle viacookies_secure: true). - Frontend sends requests with
credentials: 'include'(oraxios.defaults.withCredentials = true). - CORS middleware allows credentials and lists the frontend origin
explicitly (no
*).