Sessions and tokens
What keeps someone signed in, what ends it, and the settings that decide both.
Three things are easy to conflate, and almost every session bug comes from treating them as one:
the sessionserver-side recordOne authenticated period, on one device. It has an id, a fingerprint, and an expiry. It is what you revoke.
the access tokenshort-livedWhat travels on each request. Rotates constantly and is not worth tracking.
the refresh tokenlong-livedWhat buys the next access token. Bound to one session — not to the user.
The session lasts as long as the refresh token
This is the rule worth internalising, because it is where the settings meet reality.
When refresh tokens are enabled, the session expires when the refresh token does. The access token expiring in between is a non-event: the SDK exchanges it and the user notices nothing.
When they are disabled, the session expires when the access token does. There is nothing to renew with, so "stay signed in for 30 days" becomes "stay signed in for one hour" — the same setting, a completely different product.
These live in the console, under Authentications.
Access token expirationHow long one access token is good for. Short is safer: a leaked token has a smaller window.
Enable refresh tokenWhether sessions can outlive an access token at all.
Refresh token timeoutThe real answer to "how long do people stay signed in".
The refresh is automatic, and you should not fight it
The browser client renews on two triggers, and you drive neither. It refreshes on demand with a 90-second buffer — ask for a token that expires in less than that and it is renewed before being handed to you — and it refreshes on a timer, checking every 30 seconds so a session that nobody is using still never goes stale.
const token = await nulvless.auth.getAccessToken();That is the whole API. The mistake to avoid is caching the string:
// Wrong — this token will eventually be rejected.
const token = await nulvless.auth.getAccessToken();
setInterval(() => fetch('/api/things', { headers: auth(token) }), 60_000);Ask again per request. It is a memory read when the token is fresh, and the buffer exists precisely so it is never handed out about to expire.
For anything that holds the token outside the SDK — a websocket, a worker — subscribe instead of polling:
nulvless.auth.on('token-refreshed', (session) => {
socket.updateAuth(session.access_token);
});Knowing when it is really over
A 401 from one call does not mean the session ended. It usually means the
access token expired, and the SDK refreshes and retries.
The session is over when the refresh fails, and there is one signal for that:
nulvless.auth.on('unauthorized', () => {
clearAppState();
router.push('/sign-in');
});Do not poll getSession() to detect this. It is slower, it costs a request per
tick, and it still tells you nothing between ticks. The event fires the moment
the SDK gives up.
Ending sessions
Four ways, and they are not interchangeable:
signOut()this deviceThe ordinary case. Clears local storage only after the server confirms.
signOut('others')everywhere elseKeeps the user where they are. What "sign out my other devices" should call.
signOut('global')everywhereIncluding here. Pair it with a password change after a suspected compromise.
sessions.revoke({ id })one specific sessionFrom the "where you're signed in" list. Sudo-protected.
Because a refresh token is bound to one session, revoking the session kills its refresh token with it. There is no way for a revoked session to renew itself back into existence.
The "where you're signed in" screen
const { data: sessions } = await nulvless.auth.sessions.list();Everything the screen needs is on each entry: finger_print for the device and
location line, amr for how they signed in, created_at for when, and
is_current_session for the "this device" badge.
finger_print is populated only when the client was created with
trackUser: true — and on the server, only when you forward the user's headers
with forwardUserHeaders. Without either, every session
shows as coming from your infrastructure, which makes the screen worse than
useless: it looks like the account was accessed from a data centre.
Custom claims
An environment can inject its own claims into the access token, from a template.
If your own API reads the JWT directly rather than calling
verify, this is how it gets your fields without a
second lookup.
Prefer verify anyway when the question is about permissions — a claim is a
snapshot from when the token was minted, and a grant revoked since then is still
in it.