Users
Users client libraries
The backend, browser and React packages, and the errors they return.
Three packages, one for each side of your product. You should never need to hand-roll a request: every flow in Run a sign-in flow and every token operation in Sessions and tokens has a library call.
| Package | Runs | Key it takes |
|---|---|---|
@lessly/users | your backend | the server key, usk_ |
@lessly/users-client | the browser | the publishable key, upk_ |
@lessly/users-react | the browser, React | the publishable key, upk_ |
The split is not a matter of taste. @lessly/users holds a secret and does the things a secret authorises — verifying a token, redeeming a code, refreshing a session, verifying a webhook delivery. The browser packages hold no secret and can only run sign-up and sign-in flows and read the session they belong to. A server key sent from a browser is refused, and so is a publishable key sent to a backend endpoint.
@lessly/users-react is a thin layer over @lessly/users-client: the hooks call the same client underneath, so everything below about flow results and errors applies to both. Use the React package if you use React, and the core package for any other frontend. All three take your product id and default to the production Public Edge, so there is no base URL to configure unless you run against a local stack.
@lessly/users — your backend
npm install @lessly/usersCreate the client
import { createUsersClient } from '@lessly/users'
export const users = createUsersClient({
productId: process.env.LESSLY_PRODUCT_ID!,
serverKey: process.env.LESSLY_USERS_SERVER_KEY!,
})Both fields are required. The API address, the issuer and the JWKS URL are derived from them as {baseUrl}/{productId}/users, with baseUrl defaulting to https://public.lessly.com; baseUrl, apiUrl and issuer override that for a local stack.
The client itself has exactly three methods — verifyToken, exchangeCode and refresh. Everything else in the package is a function you compose around it: expressMiddleware, requireAuth, bearerFromHeaders, verifyTokenLocal, createJwksCache, verifyWebhook, and the error classes.
Reading the current user
expressMiddleware reads a Bearer token off the Authorization header, verifies it, and hangs the claims on req.auth. That is all it does: it mounts no route, exchanges no code and sets no cookie. How the token reaches the request — a header your frontend sets, a cookie your own backend reads and re-presents — is your decision, and step 6 of the quickstart walks one of them.
import express from 'express'
import { expressMiddleware } from '@lessly/users'
const app = express()
app.use('/api', expressMiddleware(users))
app.get('/api/me', (req, res) => {
const claims = req.auth // UsersClaims — the request got here, so it verified
res.json({
userId: claims.sub, // the stable opaque id — store this one
sessionId: claims.sid,
email: claims.email, // present only for a verified primary address
isImpersonated: claims.isImpersonated,
})
})A request without a valid token never reaches your handler: the middleware answers 401 itself and does not call next(). Inside a handler mounted behind it, req.auth is always there. property renames it, and onError takes the refusal over — see turning an unauthenticated request away.
| Claim | What it is |
|---|---|
sub | the user id. The stable one — store this, not the address |
sid | the session id |
aal | the assurance level, aal2 once a second factor was used |
authTime | when the session first authenticated, in seconds. Read this for an auth-age check, never iat — a refresh re-stamps iat every ten minutes |
email | the primary address, only when it is verified |
isImpersonated | true while an operator is signed in as this user from the management App |
metadata | the size-capped public_metadata projection |
raw | the whole payload, for claims added after this version |
Treat isImpersonated as a reason to hide destructive actions and to label the session in your own audit trail.
Verifying a token yourself
If your backend receives access tokens somewhere Express-shaped middleware does not fit — a mobile backend, a worker, a framework of its own — call the client directly, or wrap it with requireAuth, which takes the token out of whatever a request looks like in your framework and leaves verification to the SDK.
import { requireAuth, bearerFromHeaders } from '@lessly/users'
const claims = await users.verifyToken(token)
const authenticate = requireAuth(users, (ctx: MyContext) => bearerFromHeaders(ctx.headers))Verification is local. The library fetches your product’s published signing keys once, caches them for ten minutes, and checks the signature in process, so an authenticated request costs no network call. The default access token lives ten minutes, which is also the longest a revoked session can keep working on this path.
The checked mode
When a session must stop working the instant it is revoked — a ban, a sign-out from a stolen device — ask for the session to be checked as well.
const claims = await users.verifyToken(token, { checkRevoked: true })This costs one call per verification, so use it on the routes that deserve it (changing a payment method, deleting an account) rather than on everything. Sessions and tokens compares the two modes in full.
Redeeming and refreshing
A browser flow completes with a single-use code, not a token. Your backend redeems it, and from then on it holds the session.
const bundle = await users.exchangeCode(code, codeVerifier, {
redirectUri: 'http://localhost:4000/auth/callback',
})
// bundle.accessToken, bundle.refreshToken, bundle.expiresIn, bundle.session
const next = await users.refresh(bundle.refreshToken)exchangeCode takes three things: the code delivered to your callback, the PKCE verifier of the attempt that produced it, and that callback spelled exactly as you allowed it. The browser library generates the verifier and exposes it as pkceVerifier on the flow handle; sending it to your own backend alongside the code is the browser’s job. A code is single-use and lives at most a minute (codeExpiresIn).
bundle.session is the session record — id, userId, status, expiresAt. Where the bundle goes next is yours: a session cookie your backend sets on your own domain, a row in your own store, a response to a mobile client. The SDK sets no cookie.
Refresh tokens rotate: every refresh returns a new one and invalidates the one you used, so persist the new value before you use it again. Concurrent refreshes with the same token are coalesced into one call, which is what makes this safe under server-side rendering.
Turning an unauthenticated request away
There is no address on our side to send a signed-out visitor to. Sign-in is a route of your own — the one that renders your form, or the prebuilt <SignIn/> — so a request that arrives without a session goes back into your own application, carrying where the user was heading.
A request with no token never reaches your route handler: expressMiddleware rejects it and, left alone, answers 401 with a JSON body. That is what an API wants and not what a page wants, and onError is the one place the difference lives.
import { expressMiddleware } from '@lessly/users'
app.use('/dashboard', expressMiddleware(users, {
onError: (_error, req, res) => {
const from = (req as express.Request).originalUrl
;(res as express.Response).redirect(`/sign-in?continue=${encodeURIComponent(from)}`)
},
}))The two casts are the price of a package that never imports Express: the middleware types the request and the response structurally, so anything Express-shaped can mount it. Leave onError off on your JSON routes and they keep the 401.
Verifying a webhook delivery
verifyWebhook checks a delivery and hands back the parsed event, so a handler needs no crypto of its own:
import { verifyWebhook } from '@lessly/users'
app.post('/webhooks/users', express.raw({ type: 'application/json' }), (req, res) => {
const event = verifyWebhook(
req.body.toString('utf8'),
req.headers,
process.env.LESSLY_USERS_WEBHOOK_SECRET!,
)
res.sendStatus(200)
})Receive user events covers the envelope, the tolerance window and what to do with a delivery that does not verify.
Administering users
Not this package. UsersClient reads and refreshes sessions; it does not read or change the directory. Listing users, updating metadata, banning an account and revoking somebody else’s session are management-plane operations, and they run over MCP or the management API with an admin credential — see Manage your end-users.
The metadata bags a directory record carries, and who may write each:
| Bag | Written by | Read by |
|---|---|---|
publicMetadata | the management plane, over MCP or the management App | your backend and the browser; a size-capped projection travels in the access token as metadata |
privateMetadata | the management plane, over MCP or the management App | your backend only |
unsafeMetadata | the end-user’s own client, on the surfaces that accept it — users.waitlist.signup takes one | everyone |
Use unsafeMetadata for onboarding answers. Never authorise on it.
@lessly/users-client — the browser
npm install @lessly/users-clientimport { createUsersClient } from '@lessly/users-client'
export const users = createUsersClient({
productId: import.meta.env.PUBLIC_LESSLY_PRODUCT_ID,
publishableKey: import.meta.env.PUBLIC_LESSLY_USERS_PUBLISHABLE_KEY,
})The key is not a secret — it is in your bundle by design, and it only works from the origins you allowed in Configure authentication.
The flow calls sit on the client itself. Everything else hangs off a named sub-API: session, magicLink, recovery, invite, emailChange, oauth, mfa, stepUp, sessions (the end-user’s own device list), account and waitlist.
Running a flow
Sign-up and sign-in are the same shape: create an attempt, prepare a factor if it needs sending, then submit the proof. create returns a flow handle, and prepare, resend, attempt and submitSecondFactor are methods on that handle — the handle keeps the attempt’s state, so you pass only what the user typed.
const flow = await users.signIn.create({
identifier: 'ada@example.com',
redirectUri: 'http://localhost:4000/auth/callback',
})
const result = await flow.attempt({ strategy: 'password', password })
if (result.status === 'complete') {
// The code handoff: send result.code and flow.pkceVerifier to your own backend.
}result.status is the whole protocol:
| Status | What it means | What to do |
|---|---|---|
needs_first_factor | the attempt is open and waiting for a proof | show the form for one of flow.strategies |
needs_second_factor | the first factor passed, the user has a second one | ask for the code and call flow.submitSecondFactor(code) |
complete | the flow succeeded | hand result.code to your backend, or adopt the tokens on the trusted path |
failed | this attempt cannot continue | read result.error.code, start a new attempt |
flow.strategies lists the methods your product has enabled — never the methods this particular person has, and never whether the address is registered at all.
Passing redirectUri to create is what puts the flow on the code handoff: the completion carries code, redirectUri, userId, createdUser and codeExpiresIn, and the handle carries the pkceVerifier your backend needs to redeem it. Omit redirectUri only from a client that is not a public browser page, and the completion carries accessToken, refreshToken, expiresIn and session instead. isCodeHandoff(result) and isTokenCompletion(result) tell the two apart.
A factor that has to be sent is prepared first:
const flow = await users.signIn.create({ identifier: email, redirectUri })
await flow.prepare({ strategy: 'email_code' })
const result = await flow.attempt({ strategy: 'email_code', code })flow.resend() repeats whatever was last prepared, and its refusal comes back as resend_too_soon rather than a thrown error, so it wires straight to a button.
email_link sends a link instead, and both renderings finish through email_code: the page the link opens is the interstitial, and users.magicLink.info(token) describes it while consuming nothing, users.magicLink.consume(token, { csrfToken, attemptId }) acts on it. On the device that started the sign-in that completes the flow; on another device it answers the same token’s code rendering for the user to type back. oauth:google and oauth:github go through users.oauth.
users.signUp.create is the same call against the same engine — users.start is the unified entry, and the completion’s createdUser is where the two are told apart. users.resume(attemptId) rebuilds a handle after a page reload. Attempts expire — flow.expiresAt says when — and complete only once.
Session state
const { status, user, session, expiresAt } = users.session.getState()
const stop = users.session.onSessionChange((state) => {
render(state.user)
})status is 'signed-in' or 'signed-out'. user is null when nobody is signed in, and becomes null again when the session ends. The client keeps its tokens in memory only — nothing is written to storage — and users.session.getToken() refreshes an access token that is inside the thirty-second skew window before handing it back. A page adopts a session with users.session.setTokens(bundle), from a trusted-path completion or from a bundle your own backend hands it.
Signing out
await users.session.signOut() // this device
await users.session.signOut('others') // every other device
await users.session.signOut('global') // everywhere, including hereThe scope is a positional argument, one of local, others or global. local is the default. This page ends up signed out either way: the call clears the local state even when the revoke request failed.
@lessly/users-react
npm install @lessly/users-reactThe provider takes a client you construct, not a key — one client per application, so the session state has one source of truth:
import { UsersProvider } from '@lessly/users-react'
import { users } from './users-client'
export function App({ children }: { children: React.ReactNode }) {
return <UsersProvider client={users}>{children}</UsersProvider>
}useUsersClient() hands that same client back anywhere below the provider.
The hooks are headless. They run the flow and hold its state; the form, the copy and the styling are yours.
useSignIn() and useSignUp() are the same hook over the same engine. The result carries the calls directly — create, prepare, resend, attempt, submitCode, submitSecondFactor, reset — alongside status, handle, result, prepared and error:
import { useSignIn } from '@lessly/users-react'
function SignInForm() {
const { create, attempt, status, error } = useSignIn()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
async function submit(event: React.FormEvent) {
event.preventDefault()
await create({ identifier: email, redirectUri: CALLBACK })
await attempt({ strategy: 'password', password })
}
return (
<form onSubmit={submit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p role="alert">{message(error)}</p>}
<button type="submit" disabled={status === 'creating' || status === 'attempting'}>Sign in</button>
</form>
)
}error is the flow-state error code as a string (invalid_credentials, code_expired, …); transport failures throw instead. handle is the flow handle once an attempt exists, which is where pkceVerifier lives on the code-handoff path.
useUser() returns the signed-in person directly, or null; useSession() returns the session and the state around it:
const user = useUser()
// user?.id, user?.email, user?.sessionId, user?.aal, user?.isImpersonated
const { isLoaded, isSignedIn, session, user: sessionUser, aal, isImpersonated } = useSession()
// session?.id, session?.userId, session?.status, session?.expiresAtThere is no signOut on either. Signing out is useUsersClient().session.signOut(scope), or the <UserButton/>, which does it for you and takes signOutScope.
isLoaded is true in this version — the session store is synchronous, so there is no moment where a guard has nothing to render. Read it anyway: a later version restores a session on mount and will have one.
SignedIn and SignedOut render their children only in that state, and check isLoaded on their own:
function Page() {
const user = useUser()
return (
<>
<SignedOut><SignInForm /></SignedOut>
<SignedIn>Signed in as {user?.email}</SignedIn>
</>
)
}The rest of the hooks cover the surfaces around sign-in: useMagicLink, useRecovery, useEmailChange, useOAuth, useIdentities, useMfa, useStepUp, useSessions, useChangePassword and useWaitlist.
The package also ships prebuilt <SignIn/>, <SignUp/>, <UserButton/> and <UserProfile/> components, which render whole surfaces inside your own React tree for a product that would rather not draw a form. They take their appearance from your own code, not from anything stored on our side, and they run the same flows as the hooks.
One limit to know before you pick <SignIn/> over a form of your own: on the code handoff it posts the code to your redirectUri as a field named code, and onComplete hands you the FlowResult, which carries the code as well. Neither carries the PKCE verifier — it lives on the flow handle inside the component — and exchangeCode refuses a code without it. A backend that redeems the handoff itself therefore drives the flow with the hooks, where handle.pkceVerifier is in reach.
Errors
The libraries separate two kinds of failure, and you handle them differently.
Things the user did are results, not exceptions. A wrong password, an expired code, a mistyped one-time code and a rejected sign-up all come back as a returned status with a stable code you can switch on.
const result = await flow.attempt({ strategy: 'password', password })
if (result.status === 'failed') {
switch (result.error.code) {
case 'invalid_credentials': return say('That email and password do not match.')
case 'invalid_code': return say('That code is not right.')
case 'code_expired': return say('That code has expired. Send a new one.')
default: return say('We could not sign you in.')
}
}The code is at result.error.code, with result.error.message next to it. invalid_credentials covers a wrong password, an unknown address, a banned account and a locked one alike — one code for all of them is the enumeration defence, not an omission.
Codes are only ever added, never removed or given a new meaning, so a default branch is enough to keep your build working when new ones appear.
Things that went wrong are thrown. The browser packages throw a UsersClientError with a status and a code, and the subclasses carry what a caller acts on — RateLimitedError has retryAfter, AuthenticationError has hint. Your backend’s @lessly/users throws the same family under the name UsersError, with TokenInvalidError, TokenExpiredError, SessionRevokedError, CodeExchangeError, RefreshError and RateLimitedError under it.
import { RateLimitedError, UsersClientError } from '@lessly/users-client'
try {
await users.signIn.create({ identifier: email, redirectUri: CALLBACK })
} catch (err) {
if (err instanceof RateLimitedError) {
say(`Too many attempts. Try again in ${err.retryAfter} seconds.`)
} else if (err instanceof UsersClientError) {
say('We could not reach sign-in. Try again shortly.')
} else {
throw err
}
}| Status | What it means | What to do |
|---|---|---|
429 | Rate-limited. Every flow endpoint is limited per address, per source and per product. | Back off by err.retryAfter seconds rather than retrying in a loop. |
401 | The key is wrong, missing, or being used on the wrong side. | Read the message — it names which environment the key belongs to, which usually spots a development key in production. |
403 | The call came from an origin your product does not allow. | Add the exact origin, including scheme and port. |
404 | The attempt is unknown or has expired. | Start a new one. |
5xx | Ours. | Retry with backoff. Existing sessions keep working: your backend verifies access tokens locally, so it does not depend on us being reachable. |
A session that is gone shows up as a refusal, not as a mystery. On the backend verifyToken rejects with SessionRevokedError (code: 'session_revoked') in the checked mode, and with TokenExpiredError once the access token runs out; behind expressMiddleware that is the 401 your caller receives. In the browser a refused refresh clears the state to signed-out, onSessionChange fires, the SignedOut branch renders and the user signs in again.
Next steps
- Read the token contract: what these libraries wrap.
- Run a sign-in flow: each flow the browser packages drive, step by step.
- Configure authentication: the keys, the origin allowlist and the session lifetimes referred to here.