# Users client libraries

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](/ship/users/auth-flows) and every token operation in [Sessions and tokens](/ship/users/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

```bash
npm install @lessly/users
```

### Create the client

```ts

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](/ship/users/quickstart) walks one of them.

```ts

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](#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.

```ts

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.

```ts
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](/ship/users/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.

```ts
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.

```ts

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:

```ts

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](/ship/users/webhooks) 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](/ship/users/user-management).

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

```bash
npm install @lessly/users-client
```

```ts

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](/ship/users/configuration).

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.

```ts
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:

```ts
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

```ts
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

```ts
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`

```bash
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:

```tsx

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`:

```tsx

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:

```tsx
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:

```tsx
function Page() {
  const user = useUser()
  return (
    <>
      <SignedOut><SignInForm /></SignedOut>
      <SignedIn>Signed in as {user?.email}</SignedIn>
    </>
  )
}
```

> **WARNING**
> These guards are for the interface. They decide what a browser draws, never what a user is allowed to do — that check belongs on your backend, on the verified claims.

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.

```ts
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.

> **WARNING**
> Do not tell the user which half was wrong. Lessly Users deliberately answers identically whether or not an address is registered, and a helpful message on your side undoes that.

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

```ts

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](/ship/users/sessions-and-tokens): what these libraries wrap.
- [Run a sign-in flow](/ship/users/auth-flows): each flow the browser packages drive, step by step.
- [Configure authentication](/ship/users/configuration): the keys, the origin allowlist and the session lifetimes referred to here.
