Skip to content

Sessions and tokens

What a completed sign-in mints, and what your backend does with it.

A session is one signed-in device. It is the thing you revoke: ending a session signs out that laptop and nothing else, and “sign out everywhere” simply ends them all.

The contract is short. A completed flow creates a session, the session issues short-lived access tokens, and your backend verifies those tokens on its own without asking us.

The session

A session holds a refresh token, which never leaves your backend, and from it we issue access tokens, which are what your own routes look at. A user with a laptop and a phone has two sessions; revoking one leaves the other alone.

Sessions end when they are revoked, when they go unused for the inactivity window, or when they reach their absolute age. The defaults — a ten-minute access token, a thirty-day inactivity window and a one-year ceiling — are in Configure authentication.

Getting the tokens in the first place

A completed flow never hands the browser a token. It delivers a single-use code to one of your allowed callback addresses, by form POST rather than in a query string, so it does not end up in browser history or in a referrer header. The code is bound at issue to three things: the client that ran the flow, that exact callback address, and a proof the client generated when the flow started. It lives for less than a minute.

Your backend then exchanges that code for tokens, authenticating with your server key, and stores the result in a first-party cookie on your own domain. All three steps are yours to write@lessly/users gives you the exchange call and a guard for your routes, and nothing else:

  1. Your callback route receives the form POST. exchangeCode(code, codeVerifier, { redirectUri }) redeems it and answers with accessToken, refreshToken, expiresIn and the session. All three bindings are checked, and the code is consumed atomically — a second use of the same code revokes the session it minted rather than issuing another.
  2. Your code sets the cookie. The library ships no cookie helper, so the attributes are a requirement on what you write rather than something you inherit. The durable half of a session — the refresh token — is what belongs in it. Step 6 of the quickstart writes this route out in full.
  3. Because the cookie belongs to your domain, none of it depends on third-party cookies, and nothing breaks in browsers that block them.

The __Host- prefix is what makes that cookie unambiguously yours, and browsers enforce it: a cookie carrying the prefix must set Secure and Path=/, and must carry no Domain attribute at all. A session cookie wants HttpOnly on top of that, so no script can read it, and a SameSiteLax is enough for a redirect callback, because the flow returns by a top-level POST to your own origin. Its lifetime comes from what exchangeCode returned.

It does mean a backend route is part of the design: there is no path that leaves long-lived tokens in the browser. Native mobile applications use the same exchange without cookies, keeping the tokens in the platform’s secure storage.

The access token

A JSON Web Token, signed with ES256 using a keypair that belongs to your product alone. There is no shared secret anywhere in this design.

ClaimCarries
issYour product’s issuer URL
audYour product
subThe stable end-user id — the value you store in your own tables
sidThe session, so you can tell a user’s devices apart
iat, expIssued and expiry; the lifetime defaults to ten minutes
aalHow strongly this session is authenticated — a second factor raises it, and so does a passkey the device verified
amrWhich methods were used, and when — including webauthn for a passkey
emailThe primary address, present only when it is verified
actPresent only while an operator is impersonating the user

A size-capped projection of a user’s public metadata rides along too. Private metadata never does. The claim set is capped at about 1.2 KB, and the cap is enforced when you write metadata — a management write is rejected with a clear message rather than being allowed to break somebody’s sign-in later.

Verifying it

Your backend verifies access tokens locally against the keys your product publishes, so an authenticated request costs no network call to us. The keys are fetched once and cached.

import { verifyToken } from '@lessly/users'

const claims = await verifyToken(token)
// claims.sub  → the user id
// claims.sid  → the session
// claims.aal  → 'aal2' once a second factor has been used, or after a
//                verified passkey sign-in, which is two factors in one gesture

The guard does this for you and leaves the result on the request — expressMiddleware reads the bearer token, and requireAuth(users, (ctx) => ctx.cookies.session) reads the session cookie you set. If you verify by hand in a language we do not ship a library for, the contract is:

  • Configure the issuer URL and the key set URL, and do not derive either from the token. iss must equal the value you configured, and a key is looked up only in your product’s key set. A verifier that follows the token’s own pointers will accept another tenant’s tokens.
  • Ignore jku, x5u and any embedded key in the header.
  • Allow ES256 and nothing else. Require aud. Allow a minute of clock skew.
  • Cache the key set for at most ten minutes, and refetch when a token arrives with a key id you do not know — that is how key rotation reaches you without an outage.

aal and the authentication time in amr are also what you use to require a fresh or a second-factor authentication on your own sensitive routes. We enforce that on ours; on yours it is your check.

Refresh and rotation

Every refresh returns a new refresh token and retires the one used. Your backend holds the current one and calls refresh(refreshToken) before the access token expires — persist the new value before you use it again. Concurrent calls with the same token are collapsed into one, so a burst of parallel requests does not race.

Reusing a refresh token that has already been rotated is treated as theft: the whole session is revoked, on every device it covered, and an event is emitted saying why. The single exception is a narrow one — reusing the immediately previous token within about ten seconds returns the same successor, which is what makes server-rendered pages that refresh twice at once work. Anything older than that, or later than that, ends the session.

Revoking

The primary mechanism is the short access token: revoke a session and the tokens it issued stop being refreshed, so access ends within the access token lifetime — ten minutes by default. Calls to our own endpoints stop immediately, because those check the session itself.

Two modes, and the difference is what a revocation costs you:

Local verification (default)Checked verification
CallverifyToken(token)verifyToken(token, { checkRevoked: true })
Network cost per requestnoneone call to us
A revoked session stops workingwithin the access token lifetime — ten minutes by defaultimmediately
Use it onevery ordinary routethe routes that deserve it: admin actions, payments, anything irreversible

The other lever is the access token lifetime itself: lowering it shortens the window everywhere, at the price of more refreshes. It cannot go below five minutes.

Signing out comes in three scopes: this device, every other device, or all of them. “Every other device” and “all” ask for a fresh authentication first, because an attacker sitting in one session should not be able to lock the owner out of the rest.

These events end every session a user has, whatever your settings say:

  • a password change or a password reset
  • an email change, and the revert of an email change
  • a reset of a second factor
  • a ban
  • an erasure

Plan for it: a user who changes their password is signed out on their other devices, and that is intended.

Impersonation

An operator with the right permission can open a session as one of your users, from Sign in as this user in the header of that user’s record or through the product’s API, which is available to an agent. Such a session carries the act claim, is capped at thirty minutes, cannot be refreshed, and cannot change credentials, factors, addresses or sessions.

It is recorded in the audit trail with the operator’s name and — if your product enables the notification — the user is emailed about it. The server library exposes it as session.isImpersonated. Check it before you let a session do anything you would not want a support agent doing on a customer’s behalf.

Stability

The token and flow contract grows by addition only. New claims, new statuses and new optional fields appear; existing ones do not change meaning. Write your verification against the claims you use and ignore the rest, and an upgrade will not need a release on your side.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect