Documentation

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 record

One authenticated period, on one device. It has an id, a fingerprint, and an expiry. It is what you revoke.

the access tokenshort-lived

What travels on each request. Rotates constantly and is not worth tracking.

the refresh tokenlong-lived

What 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 expiration

How long one access token is good for. Short is safer: a leaked token has a smaller window.

Enable refresh token

Whether sessions can outlive an access token at all.

Refresh token timeout

The 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 device

The ordinary case. Clears local storage only after the server confirms.

signOut('others')everywhere else

Keeps the user where they are. What "sign out my other devices" should call.

signOut('global')everywhere

Including here. Pair it with a password change after a suspected compromise.

sessions.revoke({ id })one specific session

From 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.

Next

On this page