Skip to content

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.

PackageRunsKey it takes
@lessly/usersyour backendthe server key, usk_
@lessly/users-clientthe browserthe publishable key, upk_
@lessly/users-reactthe browser, Reactthe 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/users

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

ClaimWhat it is
subthe user id. The stable one — store this, not the address
sidthe session id
aalthe assurance level, aal2 once a second factor was used
authTimewhen the session first authenticated, in seconds. Read this for an auth-age check, never iat — a refresh re-stamps iat every ten minutes
emailthe primary address, only when it is verified
isImpersonatedtrue while an operator is signed in as this user from the management App
metadatathe size-capped public_metadata projection
rawthe 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:

BagWritten byRead by
publicMetadatathe management plane, over MCP or the management Appyour backend and the browser; a size-capped projection travels in the access token as metadata
privateMetadatathe management plane, over MCP or the management Appyour backend only
unsafeMetadatathe end-user’s own client, on the surfaces that accept it — users.waitlist.signup takes oneeveryone

Use unsafeMetadata for onboarding answers. Never authorise on it.

@lessly/users-client — the browser

npm install @lessly/users-client
import { 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:

StatusWhat it meansWhat to do
needs_first_factorthe attempt is open and waiting for a proofshow the form for one of flow.strategies
needs_second_factorthe first factor passed, the user has a second oneask for the code and call flow.submitSecondFactor(code)
completethe flow succeededhand result.code to your backend, or adopt the tokens on the trusted path
failedthis attempt cannot continueread 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 here

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

The 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?.expiresAt

There 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
  }
}
StatusWhat it meansWhat to do
429Rate-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.
401The 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.
403The call came from an origin your product does not allow.Add the exact origin, including scheme and port.
404The attempt is unknown or has expired.Start a new one.
5xxOurs.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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect