Skip to main content

Sessions & refresh

A session in IdentSphere is a row in user_sessions plus a pair of cookies on the client.

Tokens

TokenLifetimeStoragePurpose
Access token15 min defaultidentsphere_at cookie + Authorization headerAuthenticates every request. JWT with sub, org, email, aal, mfa_verified_at claims.
Refresh token30 days defaultidentsphere_rt cookieExchanged at /refresh for a new access token. Opaque; SHA-256 hashed in DB.
CSRF tokenmatches access TTLidentsphere_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:

  1. Persist the new refresh token on every refresh. Each /refresh returns 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 read refresh_token from the response body — present only in body mode (you sent the token in the request body, not a cookie) — and overwrite their stored copy.
  2. Single-flight concurrent refreshes. If two requests 401 at once and both call /refresh with 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:

ActionEndpoint
Sign out currentPOST /v1/auth/logout
Sign out one specificDELETE /v1/auth/sessions/:id
Sign out everywhereDELETE /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:

  1. Verify the access token normally (JWKS, iss/exp/aud).
  2. Fetch the feed (cache it for ttl seconds — 5s) and reject the token if its sid is in revoked_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_SECS instead — 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-Cookie with SameSite=None and Secure (toggle via cookies_secure: true).
  • Frontend sends requests with credentials: 'include' (or axios.defaults.withCredentials = true).
  • CORS middleware allows credentials and lists the frontend origin explicitly (no *).