# 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`, `passkeys`, `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.

### Signing in with a passkey

`@lessly/users-client` 0.8.0 adds the whole ceremony as one call. It opens a discoverable attempt, asks for the challenge, runs `navigator.credentials.get()` and submits the assertion:

```ts
const result = await users.passkey.signIn()
```

The result is an ordinary flow result — `complete`, `needs_second_factor` or `failed` — and the completion carries a code or the tokens by the same rule as any other method.

**The browser's own autofill offer** is the same call with a mediation:

```ts
const controller = new AbortController()
void users.passkey.signIn({ mediation: 'conditional', signal: controller.signal })
```

Start it when the page loads and never from a click — the browser refuses conditional mediation that follows a gesture — mark the email field `autocomplete="username webauthn"`, and abort it when the user commits to another way in. The React hook does all three for you.

Feature detection decides whether to draw any of it. All three answer `false` rather than throwing where there is no browser, so they are safe in a server-rendered bundle:

```ts
  isConditionalMediationAvailable,
  isPasskeySupported,
  isPlatformAuthenticatorAvailable,
} from '@lessly/users-client'
```

> **NOTE**
> **A dismissed prompt throws `PasskeyCeremonyError`**, whose `reason` is `aborted`, `not_allowed`, `unsupported` or `failed`. Closing the sheet is an ordinary thing to do: render nothing for `aborted`. A refusal from the server — `passkey_not_enabled` when the product is not a relying party — comes back as an ordinary failure result instead, because it is an answer and not a fault.

### Passkeys on the account

`users.passkeys` is the settings surface. Everything but the listing is sensitive, so it takes the same `{ code }` a step-up prompt collected, or throws `StepUpRequiredError` for you to prompt and retry:

```ts
const { passkeys } = await users.passkeys.list()
await users.passkeys.register({ name: 'MacBook' })
await users.passkeys.rename('pk_1', 'Phone')
await users.passkeys.remove('pk_1')
```

A row carries `id`, `name`, `transports`, `aaguid`, `backedUp`, `signCount`, `createdAt` and `lastUsedAt`. The public key is never on the wire: it is a stable cross-site fingerprint of the device holding it. Removing the account's last way in comes back as an ordinary failure carrying `passkey_last_way_in` — policy, not transport, and no step-up grant overrides it.

**The step-up prompt takes a passkey too.** An account whose only credential is a passkey has no code to type, and would otherwise be locked out of the screen that manages it:

```ts
await users.stepUp.verifyWithPasskey({ operation: 'passkey_manage' })
```

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

**`usePasskeySignIn()`** is the sign-in half, both ways a browser offers one. `supported` says whether to draw the button at all, `autofillAvailable` whether the browser can put a passkey in its autofill dropdown, and `startAutofill()` begins that offer and returns its own abort:

```tsx
const passkey = usePasskeySignIn()

useEffect(() => {
  if (!passkey.autofillAvailable) return
  return passkey.startAutofill()
}, [passkey.autofillAvailable, passkey.startAutofill])

<button disabled={passkey.busy} onClick={() => passkey.signIn()}>Use a passkey</button>
```

Start the autofill offer from an effect and never from a click — the browser refuses conditional mediation that follows a gesture — and mark the email field `autocomplete="username webauthn"` while it is live. A **dismissed prompt answers `null`** and sets `error` to the ceremony's reason (`aborted`, `not_allowed`, `unsupported`, `failed`); render nothing for it.

**`usePasskeys()`** is the settings half — `passkeys`, `refresh`, `register`, `rename`, `remove`, plus `supported` and the usual `busy` and `error`. It takes the same step-up posture as `useMfa`: pass the `{ code }` a prompt collected to any mutating call, or catch `StepUpRequiredError` and prompt. The passkey answer to that prompt is `useStepUp().verifyWithPasskey('passkey_manage')`, which is the only answer an account whose sole factor is a passkey has.

The rest of the hooks cover the surfaces around sign-in: `useMagicLink`, `useRecovery`, `useEmailChange`, `useOAuth`, `useIdentities`, `useMfa`, `useStepUp`, `useSessions`, `useChangePassword` and `useWaitlist`.

### The prebuilt components

The package's other layer renders whole surfaces inside your own React tree, for a product that would rather not draw a form. They run the same flows as the hooks.

| Component | What it renders |
|---|---|
| `<SignIn/>` | The whole sign-in flow — identifier, password, one-time code, magic link, OAuth, a passkey, a second factor, lockout — screen by screen |
| `<SignUp/>` | The same for sign-up, including the fields your configuration requires |
| `<UserButton/>` | An avatar and a menu for a signed-in user: their account, their devices, and the way out. Renders nothing when signed out |
| `<UserProfile/>` | The account panel: profile, ways to sign in, two-step verification, passkeys, and the devices this person is signed in on |
| `<MfaSettings/>` | Two-step verification on its own: the factor list, enrolment from a QR code, the backup-code sheet, regenerate and disable |
| `<PasskeySettings/>` | Passkeys on their own: the list, adding one, renaming a row, removing one, and the prompt in front of each |
| `<QrCode/>` | Just the code — the encoder `<MfaSettings/>` draws its QR with, exported for a screen of your own |

`<MfaSettings/>` and `<QrCode/>` need `@lessly/users-react` 0.9.0 or later. Everything about **passkeys** — the hooks, the ceremony layer and `<PasskeySettings/>` — needs `@lessly/users-react` 0.10.0 and `@lessly/users-client` 0.8.0. The sign-in components' `codeChallenge` needs 0.8.0 / 0.7.0; there is no published `@lessly/users-react` 0.7.0, so an upgrade goes from 0.6.0 to 0.8.0 and picks up the `Hosted*` → `Auth*` rename of the theme and copy symbols along the way.

```tsx

export function Page({ codeChallenge, state }) {
  return (
    <>
      <SignedOut>
        <SignIn
          redirectUri="https://app.example.com/auth/callback"
          codeChallenge={codeChallenge}
          state={state}
        />
      </SignedOut>
      <SignedIn>
        <UserButton />
      </SignedIn>
    </>
  )
}
```

`redirectUri` is your callback, spelled exactly as it appears on your redirect allowlist. Giving it puts the finished flow on the ordinary code handoff: the component form-POSTs a single-use code to that address — never a query string, because a code in a URL is a code in a referrer, a log and a history entry.

`codeChallenge` is the other half of that handoff, and it is **required whenever `redirectUri` is set**. It is the RFC 7636 S256 challenge *your backend* minted, keeping the matching verifier: the exchange demands that verifier, and a verifier sitting in the same document as the code would travel to the same address as the code and collapse the binding PKCE exists to make. So the component never sees a verifier, and the browser library mints none when a challenge is supplied.

> **WARNING**
> Give a `redirectUri` without a `codeChallenge` and the component **refuses at render**. It shows an error screen instead of the form, calls `onError('code_challenge_required')`, and never asks for an email address — a misconfigured sign-in must not spend someone's credentials on a code nobody can redeem. The refusal holds even when you pass `onComplete`.

`state` is your own opaque value — a CSRF token, or where to send the user afterwards. It is echoed back to your callback as a second hidden field, and Lessly Users neither reads it nor stores it.

`<SignIn/>` renders a **passkey button** whenever the browser can run a ceremony, and while the form is open it offers a passkey in the browser's own autofill dropdown over the email field, which it marks `autocomplete="username webauthn"` while the offer is live. It also offers a passkey on the second-factor screen when the parked attempt named one. Pass `passkeys={false}` to turn all of it off. `<SignIn/>` and `<SignUp/>` also take `captchaToken`, `onComplete` — which hands you the completion and stops the component navigating anywhere — and `onError`.

Leaving `redirectUri` off altogether is the third case: the flow takes the trusted path and answers with tokens, adopted into the client's session store and never rendered. That is for a client that is not a public browser page.

#### The backend half

Two things happen on your side: mint the pair before the page renders, and exchange the code when it comes back.

```ts

const base64url = (bytes: Buffer) => bytes.toString('base64url')

// Mint the pair where the browser cannot reach it, and keep the verifier.
app.get('/sign-in', (req, res) => {
  const verifier = base64url(randomBytes(32))
  req.session.pkceVerifier = verifier

  res.render('sign-in', {
    codeChallenge: base64url(createHash('sha256').update(verifier).digest()),
    state: req.session.csrfToken,
  })
})

// The component form-POSTs `code`, and `state` when you supplied one.
app.post('/auth/callback', async (req, res) => {
  if (req.body.state !== req.session.csrfToken) return res.sendStatus(400)

  const bundle = await users.exchangeCode(req.body.code, req.session.pkceVerifier, {
    redirectUri: CALLBACK,
  })

  delete req.session.pkceVerifier
  setYourOwnSessionCookie(res, bundle)
})
```

This is what the prebuilt components cost, stated plainly: no authentication UI of your own, and two steps on your backend. It is not no code at all.

`<UserButton/>` takes `signOutScope` (`local` by default, or `others` or `global`), `afterSignOut`, and `onProfile` / `onSessions` to take those menu clicks yourself. `<UserProfile/>` takes `sections` — any of `profile`, `identifiers`, `security` and `sessions` — and a section left out costs no request.

#### `<MfaSettings/>` and `<PasskeySettings/>`

Both render `null` when nobody is signed in, and both take the same four props:

| Prop | Default | What it does |
|---|---|---|
| `chrome` | `'card'` | `'none'` drops the card and draws on your own surface |
| `heading` | `'Two-step verification'` / `'Passkeys'` | The heading above the section |
| `onChange` | — | Called with the factors, or the passkeys, after a mutation |
| `theme`, `className` | — | The same token set as the other components |

Both handle **step-up** themselves — the server asks for a recent sign-in before a sensitive change, the component renders the prompt and resumes where it left off, so `StepUpRequiredError` never reaches your code on these paths — and both render **policy refusals** as sentences: `last_factor_required` and `last_way_in` for factors, `passkey_last_way_in` for a passkey. The passkey prompt offers **"Use a passkey instead"** beside the code box, which is what keeps a passkey-only account out of a lockout.

`<UserProfile/>`'s `security` section embeds both, so a product that already renders the account panel has full self-service for each and needs no separate screen. [Add two-factor authentication](/ship/users/mfa) and [Add passkeys](/ship/users/passkeys) are the two subjects end to end.

#### Theming them

Every component takes a `theme` — a partial token set — and a `className`. The theme comes from your own code rather than from anything stored on our side:

```tsx
<SignIn theme={{ productName: 'Acme', accentColor: '#4f46e5', borderRadius: '8px' }} />
```

Keys that are not part of the token set are dropped and values are sanitised, so a component cannot be styled into something the token set would refuse. The resolved tokens are written on the component's root as CSS custom properties — `--lessly-accent`, `--lessly-bg`, `--lessly-surface`, `--lessly-text`, `--lessly-muted` and `--lessly-radius` — and those names are part of the API, so styling around a component targets them.

The components control appearance, not layout: you cannot reorder the screens or add fields of your own. When the sign-in screen is part of the product experience rather than a door in front of it, drop to the hooks and write the form. What changes between the two is only which side mints the PKCE pair.

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