Users
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 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.
- The middleware from
@lessly/usersdoes the whole exchange, sets the cookie (__Host-prefixed,HttpOnly,Secure,SameSite=Lax) and refreshes it from then on — the four lines in step 6 of the quickstart. - Because the cookie belongs to your domain, none of it depends on third-party cookies, and nothing breaks in browsers that block them.
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.
| Claim | Carries |
|---|---|
iss | Your product’s issuer URL |
aud | Your product |
sub | The stable end-user id — the value you store in your own tables |
sid | The session, so you can tell a user’s devices apart |
iat, exp | Issued and expiry; the lifetime defaults to ten minutes |
aal | How strongly this session is authenticated — a second factor raises it |
amr | Which methods were used, and when |
email | The primary address, present only when it is verified |
act | Present 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 usedThe middleware does this for you and leaves the result on the request. 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.
issmust 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,x5uand any embedded key in the header. - Allow
ES256and nothing else. Requireaud. 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; the middleware refreshes before the access token expires, and collapses concurrent refreshes 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 | |
|---|---|---|
| Call | verifyToken(token) | verifyToken(token, { checkRevoked: true }) |
| Network cost per request | none | one call to us |
| A revoked session stops working | within the access token lifetime — ten minutes by default | immediately |
| Use it on | every ordinary route | the 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 the management App. 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
- Run a sign-in flow: how a session gets created in the first place.
- Configure authentication: the lifetimes and the two keys this page assumes.
- Receive user events: being told about a revocation instead of finding out at the next request.
- Use the client libraries: the two verification modes as the server package exposes them.