Users client libraries
Choose Users SDK imports and look up client, hook, component, and error contracts.
Install @lepsto/sdk for the Users server client, browser client, React hooks, and prebuilt components. Choose the import by execution environment and the UI you want to build.
npm install @lepsto/sdkUse SDK 1.0.0 or later for the current backend code-exchange credential. The published 1.2.0 package requires Node 20 or later and has no runtime dependencies. React, React DOM, and @react-email/render are optional peers; install the peers needed by the subpaths you use.
Which subpath to import
The backend credential and browser publishable key have different purposes:
Subpath | Runs in | Credential |
|---|---|---|
@lepsto/sdk/users/server | Backend | Product public key, lpk_, for code exchange |
@lepsto/sdk/users | Browser | Users publishable key, upk_ |
@lepsto/sdk/users/react | React client: hooks and guards | Browser client supplied through UsersProvider |
@lepsto/sdk/users/react/ui | React client: components | Same provider and browser client |
Keep lpk_ on the backend despite its “public key” name. Code exchange uses it with a route scope for POST /code/exchange. Refresh uses the refresh token in the request body, and local verification uses the product’s public signing keys. The browser’s upk_ identifies the product for public authentication flows; it is not a substitute for the backend credential.
React hooks and components use the same browser client as @lepsto/sdk/users, with the same flow outcomes and errors. Use the headless browser client directly for a non-React frontend.
Supply the product ID and appropriate key when constructing a client. Production URLs are derived by default. The server client also accepts explicit URL overrides for supported custom environments.
Moving off the standalone packages
The standalone @lessly/* packages have been superseded by these imports. Move to the current SDK and update credential options; do not assume an old usk_ server-key integration still authenticates.
Superseded package | Import from |
|---|---|
@lessly/users | @lepsto/sdk/users/server |
@lessly/users-client | @lepsto/sdk/users |
@lessly/users-react | @lepsto/sdk/users/react for the hooks, the provider and the guards; @lepsto/sdk/users/react/ui for the prebuilt components |
Prebuilt components now live in the UI subpath. A page using both guards and components imports from both React entries. Retain the published error identifiers used by the entry you import.
@lepsto/sdk/users/server — your backend
The server entry resolves to a throwing stub under the browser export condition. Import it only in backend code. Its lpk_ credential must never enter a browser bundle.
Create the client
import { createUsersClient } from '@lepsto/sdk/users/server'
export const users = createUsersClient({
productId: process.env.LEPSTO_PRODUCT_ID!,
publicKey: process.env.LEPSTO_USERS_PUBLIC_KEY!,
})The client exposes verifyToken, exchangeCode, refresh, config, and jwks. Compose the separate expressMiddleware or requireAuth functions around it. Create the backend product key through the management API with scope limited to Users POST /code/exchange, and allow about thirty seconds for it to propagate. The Public Access UI selects all or selected toolkits; saving a route-scoped key there replaces its route scope and widens access. See Get the keys for the scope entry and key-handling steps.
The guard
expressMiddleware verifies the request’s bearer token and stores claims on req.auth by default:
import express from 'express'
import { createUsersClient, expressMiddleware } from '@lepsto/sdk/users/server'
const app = express()
app.use('/api', expressMiddleware(users))
app.get('/api/me', (req, res) => {
res.json({
userId: req.auth.sub, // the stable opaque id — store this one
sessionId: req.auth.sid,
email: req.auth.email, // present only if the address is verified
aal: req.auth.aal, // 'aal2' once a second factor passed
})
})Mount the guard only on authenticated routes. Missing, expired, or invalid tokens receive 401 and do not reach downstream handlers. Successful requests have verified claims. For a route that intentionally serves signed-in and signed-out callers, perform optional verification in your own handler.
Option | What it does |
|---|---|
onError | Answer refusals in your own shape |
property | Hang the claims somewhere other than req.auth |
checkRevoked | Spend a round trip confirming the session is still alive |
Use requireAuth(client, getToken) in other frameworks. The extractor supplies the access token from your request structure.
Verifying a token yourself
Call verifyToken directly when receiving an access token from a mobile client or another service:
import { createUsersClient } from '@lepsto/sdk/users/server'
const users = createUsersClient({
productId: process.env.LEPSTO_PRODUCT_ID!,
publicKey: process.env.LEPSTO_USERS_PUBLIC_KEY!,
})
const claims = await users.verifyToken(token)
// claims.sub — the user id
// claims.sid — the session id
// claims.aal — 'aal1', or 'aal2' if a second factor was used
// claims.email — the primary address, only when it is verifiedLocal verification checks the signature against cached product signing keys. It normally requires no network request. A revoked session’s existing token can remain valid until its expiry, ten minutes by default. See Authentication configuration.
The checked mode
To reject a revoked session immediately, enable checked verification:
const claims = await users.verifyToken(token, { checkRevoked: true })Checked mode adds one request per verification. Use it where immediate revocation matters, such as payment changes or account deletion. Sessions and tokens compares both modes.
Redeeming and refreshing by hand
Call exchange from your backend callback and refresh from your session-management code:
const bundle = await users.exchangeCode(code, codeVerifier, { redirectUri: CALLBACK })
// bundle.accessToken, bundle.refreshToken, bundle.expiresIn, bundle.session
const next = await users.refresh(bundle.refreshToken)exchangeCode requires the delivered code, its matching PKCE verifier, and the exact redirectUri used to start the attempt. Codes work once and expire in about a minute.
PublicKeyRequiredError, PublicKeyInvalidError, and PublicKeyScopeError identify credential failures before the code is checked. For a scope error, correct the valid key’s scope. CodeExchangeError identifies a refused code, verifier, or callback. A 503 public_config_unavailable is a transient service configuration failure; retry with backoff.
The verifier’s location depends on the integration:
- Redirecting prebuilt
<SignIn/>or<SignUp/>: generate the pair on the backend, retain the verifier, and pass only the challenge to the component. Retrieve the verifier in the backend callback. - Headless hooks with your own form: the browser generates the pair when no external challenge is supplied. Send the code and
handle.pkceVerifiertogether to your backend.
In both cases, the backend performs the exchange with lpk_. Validate the callback’s state and protect your application’s cookie-authenticated actions against CSRF.
Refresh replaces the refresh token. Persist the returned value before another refresh. The client coalesces concurrent refreshes using the same token; Sessions and tokens explains the reuse window and revocation behavior.
Turning an unauthenticated request away
Render sign-in on a route in your own application. A page without a valid session can redirect there and preserve its destination. The helper functions in this example represent your application’s cookie and rendering code:
app.get('/dashboard', async (req, res) => {
const signIn = () =>
res.redirect(`/sign-in?continue=${encodeURIComponent(req.originalUrl)}`)
const token = yourSessionCookie(req)
if (!token) return signIn()
try {
renderDashboard(res, await users.verifyToken(token))
} catch {
signIn()
}
})This example handles a browser page with a redirect. Authenticated API routes can use the guard’s 401 response instead.
What this client is not
This server client provides token verification, code exchange, and refresh.
Directory operations such as searching users, changing metadata, banning accounts, or revoking another user’s sessions use the app or management API.
End-user sessions and operator identities carry different rights. See User management for operator access and metadata rules.
@lepsto/sdk/users — the browser
import { createUsersClient } from '@lepsto/sdk/users'
export const users = createUsersClient({
productId: process.env.PUBLIC_LEPSTO_PRODUCT_ID!,
publishableKey: process.env.PUBLIC_LEPSTO_USERS_PUBLISHABLE_KEY!,
})The upk_ key is intended for the browser bundle. Browser requests must come from your allowed origins.
Running a flow
create returns a handle for one sign-in or sign-up attempt. Prepare the method if necessary, then submit proof through that handle. Replace the address and callback below with your own values:
const handle = await users.signIn.create({
identifier: 'ada@example.com',
redirectUri: 'https://app.example.com/auth/callback',
})
const result = await handle.attempt({ strategy: 'password', password })
if (result.status === 'complete' && 'code' in result) {
// `result.code` is single-use. Post it to your backend with
// `handle.pkceVerifier`, which is the half that proves the redemption.
}Handle the returned status:
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 handle.strategies |
needs_second_factor | the first factor passed, the user has MFA | collect the code and call handle.submitSecondFactor(code) |
complete | the flow succeeded | post 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 |
With redirectUri, completion returns code, redirectUri, and codeExpiresIn. Post the code and verifier to your backend. Without redirectUri, the trusted path returns tokens directly; it is not the public-browser handoff.
Before a factor succeeds, handle.strategies lists product-enabled methods without revealing the user’s methods or whether the address is registered.
For an email code, prepare the method before attempting it. This excerpt assumes an existing handle:
await handle.prepare({ strategy: 'email_code' })
const result = await handle.attempt({ strategy: 'email_code', code })handle.resend() repeats the last prepared method. Respect the server cooldown: resend_too_soon leaves the attempt active so the user can wait and retry.
Prepare email_link similarly. users.magicLink supports an interstitial that reads the link with GET and consumes it only after POST confirmation. Same-device confirmation completes the original attempt. On another device, the user receives a short code to submit on the starting device with email_code. No polling call is required.
For Google or GitHub, users.oauth.start() navigates to the provider. On return, users.oauth.completeFromCallback() reads the URL fragment, submits its handoff with the stored client secret, clears the fragment, and returns the outcome. It also completes pending links through identities.linkComplete({ handoff }). The OAuth handoff does not require cookies or credentials: 'include'. See Authentication flows for fragments and expiry windows.
Set storage: localStorageAdapter() so OAuth attempt data survives navigation.
users.signUp.create and users.signIn.create use the same flow engine. Completion reports createdUser when it created an account; separate method names support separate UI entry points.
Check handle.expiresAt for the attempt deadline. An attempt completes at most once.
Signing in with a passkey
users.passkey.signIn creates a discoverable attempt, requests a challenge, runs navigator.credentials.get(), and submits the assertion. For a public browser page, your backend must first generate the PKCE pair and retain the verifier. Pass only the challenge to this helper, together with your exact allowlisted callback:
const redirectUri = 'https://app.example.com/auth/callback'
const result = await users.passkey.signIn({ redirectUri, codeChallenge })
if (result.status === 'complete' && 'code' in result) {
postPasskeyCode(result.code, state)
}
function postPasskeyCode(code: string, state: string) {
const form = document.createElement('form')
form.method = 'POST'
form.action = redirectUri
for (const [name, value] of Object.entries({ code, state })) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = name
input.value = value
form.append(input)
}
document.body.append(form)
form.submit()
}Replace the callback with yours. codeChallenge and state come from the backend that rendered this page. At the callback, validate state against the initiating session, exchange the code with the retained verifier, and establish your application session as in the quickstart. The helper returns only the flow outcome, so a browser-generated verifier is not available through it.
Handle non-complete outcomes in your form. If you need to continue a second-factor challenge, retain an attempt handle and use handle.preparePasskey(), handle.submitPasskey(), or handle.signInWithPasskey(). The prebuilt <SignIn/> handles these branches for you.
For conditional autofill, supply the same callback and challenge, plus mediation and a cancellation signal. Handle its completion with the same form POST:
const controller = new AbortController()
const result = await users.passkey.signIn({
redirectUri,
codeChallenge,
mediation: 'conditional',
signal: controller.signal,
})
if (result.status === 'complete' && 'code' in result) {
postPasskeyCode(result.code, state)
}Start conditional mediation when the page loads, mark the email field autocomplete="username webauthn", and abort when the user chooses another method. The React hook manages ceremony cleanup; your markup supplies the autocomplete attribute.
Use the feature helpers before offering passkeys. They return false outside a browser, making them safe to call during server rendering:
import {
isConditionalMediationAvailable,
isPasskeySupported,
isPlatformAuthenticatorAvailable,
} from '@lepsto/sdk/users'Passkeys on the account
Use users.passkeys for account settings. Mutations require fresh authentication: provide the { code } from your step-up prompt or handle StepUpRequiredError and retry after proof:
const { passkeys } = await users.passkeys.list()
await users.passkeys.register({ name: 'MacBook' }) // options, ceremony, redemption
await users.passkeys.rename('pk_1', 'Phone')
await users.passkeys.remove('pk_1')Rows include id, name, transports, aaguid, backedUp, signCount, createdAt, and lastUsedAt. Listing omits credential public keys. For a staged registration UI, call registerOptions() and add() separately. Registration challenges are bound to the session that requested them and cannot be redeemed from another session.
Removal that would leave no sign-in method returns passkey_last_way_in. A step-up grant does not override this policy.
Step-up also accepts a passkey, allowing passkey-only accounts to manage their credentials:
await users.stepUp.verifyWithPasskey({ operation: 'passkey_manage' })Session state
Read and subscribe to the client’s session store:
const { status, user, session, expiresAt } = users.session.getState()
const stop = users.session.onSessionChange((state) => {
render(state.user)
})
// Trusted-client example: adopt a returned bundle.
users.session.setTokens(bundle)
// The current access token, refreshed first if it is about to expire.
const token = await users.session.getToken()The store’s user is null while signed out. When the client learns that the session ended, it updates state and notifies subscribers, including after cross-tab sign-out or revocation. Access tokens are held in memory, not localStorage, and refreshed while the client has a valid session. Keep long-lived browser-session credentials under backend control; the token-bundle methods also support trusted clients.
Signing out
await users.session.signOut() // this device
await users.session.signOut('others') // every other device
await users.session.signOut('global') // everywhere, including hereSigning out others or global requires recent authentication. If it is too old, handle StaleAuthenticationError by sending the user through sign-in again.
@lepsto/sdk/users/react and @lepsto/sdk/users/react/ui
The React entry contains UsersProvider, hooks, and SignedIn / SignedOut guards. The UI entry adds complete screens and theme helpers. Import both when a component sits inside a guard.
Separate entries let a custom-form application omit the prebuilt UI bundle, roughly 70 KB of unminified ESM in SDK 1.2.0.
Both React entries declare 'use client'. In Next.js App Router they define client boundaries; construct and supply the browser client within the appropriate client component.
Create one browser client and pass it to UsersProvider:
import { createUsersClient } from '@lepsto/sdk/users'
import { UsersProvider } from '@lepsto/sdk/users/react'
const users = createUsersClient({
productId: process.env.PUBLIC_LEPSTO_PRODUCT_ID!,
publishableKey: process.env.PUBLIC_LEPSTO_USERS_PUBLISHABLE_KEY!,
})
export function App({ children }: { children: React.ReactNode }) {
return <UsersProvider client={users}>{children}</UsersProvider>
}Use prebuilt components for complete authentication screens or headless hooks when you need your own markup. Both use the same flow contracts. You can combine them within an application.
The prebuilt components
Import these components from @lepsto/sdk/users/react/ui:
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 |
import { SignedIn, SignedOut } from '@lepsto/sdk/users/react'
import { SignIn, UserButton } from '@lepsto/sdk/users/react/ui'
export function Page({ codeChallenge, state }) {
return (
<>
<SignedOut>
<SignIn
redirectUri="https://app.example.com/auth/callback"
codeChallenge={codeChallenge}
state={state}
/>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</>
)
}Set redirectUri to the exact allowlisted callback. On completion, the component form-POSTs the single-use code there. It keeps the code out of URL queries, history, and referrer headers. Use this handoff for a public browser page.
A redirecting component also requires codeChallenge: the RFC 7636 S256 challenge generated by your backend. Keep the matching verifier on that backend. When an external challenge is supplied, the browser client does not generate another pair.
state is an opaque application value returned as a hidden callback field. Use it to bind the callback to the initiating session and validate it on your backend. Users does not interpret or store it.
<SignIn/> offers a passkey button when WebAuthn is available, conditional autofill over its email field when supported, and a passkey second-factor option when the attempt lists one. It does not wait for an entered address to offer discoverable sign-in. Use passkeys={false} to disable these offers. A product-side passkey_not_enabled refusal is rendered as explanatory text.
Both sign-in components accept captchaToken, onComplete, and onError. Supply your CAPTCHA widget’s token when required. onComplete receives completion and stops automatic navigation. onError receives flow error codes as the component renders them.
Without redirectUri, the component uses the trusted path and adopts returned tokens into the client store. This path is not for a public browser page.
The backend half
Generate and retain the verifier before rendering, then exchange the returned code. This excerpt assumes an Express app with body parsing, server-side sessions, a generated CSRF token, and application-defined rendering and cookie helpers:
import { createHash, randomBytes } from 'node:crypto'
import { createUsersClient } from '@lepsto/sdk/users/server'
const users = createUsersClient({
productId: process.env.LEPSTO_PRODUCT_ID!,
publicKey: process.env.LEPSTO_USERS_PUBLIC_KEY!,
})
const CALLBACK = 'https://app.example.com/auth/callback'
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)
})Keep the verifier tied to the initiating session or attempt, and delete it after exchange. The code is single-use and lasts at most a minute. Ensure your session is saved before rendering the page, and that the callback can recover it.
Your application also owns secure cookie attributes and CSRF protection. See the quickstart for those requirements.
<UserButton/> accepts signOutScope (local, others, or global), afterSignOut, and onProfile / onSessions callbacks to replace its built-in panels. <UserProfile/> accepts any combination of profile, identifiers, security, and sessions in sections; omitted sections make no request.
<MfaSettings/> and <PasskeySettings/>
Both settings components render null while signed out. <MfaSettings/> provides the factor list, QR and manual secret, confirmation, one-time backup codes, regeneration, and disabling. <PasskeySettings/> provides named credentials, transport and usage details, and add, rename, and remove actions.
import { MfaSettings, PasskeySettings } from '@lepsto/sdk/users/react/ui'
export function SecuritySettings() {
return (
<>
<MfaSettings />
<PasskeySettings />
</>
)
}Their shared props are:
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 and hook as the other components |
Both components render and complete step-up challenges before resuming mutations. They also explain last_factor_required, last_way_in, and passkey_last_way_in without removing the affected credential. On supported browsers, the prompt includes Use a passkey instead, allowing passkey-only users to complete it.
The security section of <UserProfile/> includes both components. See MFA and Passkeys for complete setup and verification steps.
Theming them
Pass a partial theme and optional className to each component. Themes come from your application code, not product configuration. This theme-only excerpt uses replaceable example values; a redirecting sign-in also needs its callback, challenge, and state:
<SignIn theme={{ productName: 'Acme', accentColor: '#4f46e5', borderRadius: '8px' }} />Unknown theme keys are dropped and values are sanitized. Resolved values become root CSS properties: --lepsto-accent, --lepsto-bg, --lepsto-surface, --lepsto-text, --lepsto-muted, and --lepsto-radius. Your stylesheet can reuse these API names.
Theme options change appearance, not screen order or fields. Use hooks for custom forms. The flow and backend exchange remain the same, but hooks can generate the PKCE pair in the browser while redirecting components require a backend-generated challenge.
The headless hooks
Hooks manage flow state while your application supplies markup, wording, and styling. The available hooks are useSignIn, useSignUp, useSession, useSessions, useUser, useIdentities, useMfa, useOAuth, useMagicLink, usePasskeySignIn, usePasskeys, useRecovery, useChangePassword, useEmailChange, useStepUp, and useWaitlist.
useSignIn() and useSignUp() return status, handle, result, error, and prepared, plus create, attempt, prepare, resend, submitCode, submitSecondFactor, submitSecondFactorPasskey, and reset. Hook prepare takes a strategy string, such as prepare('email_code'); handle prepare takes an object. Capture the handle returned by create when using it within the same submission:
import { useState } from 'react'
import { useSignIn } from '@lepsto/sdk/users/react'
const CALLBACK = 'https://app.example.com/auth/callback'
function SignInForm() {
const { status, error, create, attempt } = useSignIn()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
async function submit(event: React.FormEvent) {
event.preventDefault()
const handle = await create({ identifier: email, redirectUri: CALLBACK })
const result = await attempt({ strategy: 'password', password })
if (result.status === 'complete' && 'code' in result) {
await fetch('/auth/complete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: result.code, verifier: handle.pkceVerifier }),
})
}
}
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 === 'attempting'}>Sign in</button>
</form>
)
}With redirectUri and no external codeChallenge, the browser generates a PKCE pair. Send the returned handle’s pkceVerifier and completion code to your backend, which performs the exchange. Protect that endpoint according to your application’s CSRF design. The snippet shows the password handoff; handle the other returned statuses as described in Authentication flows.
The hook’s error is a code string such as invalid_credentials, not an error object. In the example, message is your application’s code-to-message function.
usePasskeySignIn() exposes supported for the button and autofillAvailable for conditional mediation. startAutofill() begins the offer and returns a cleanup function:
const passkey = usePasskeySignIn()
useEffect(() => {
if (!passkey.autofillAvailable) return
return passkey.startAutofill({ redirectUri, codeChallenge })
}, [passkey.autofillAvailable, passkey.startAutofill, redirectUri, codeChallenge])
useEffect(() => {
const result = passkey.result
if (result?.status === 'complete' && 'code' in result) {
postPasskeyCode(result.code, state)
}
}, [passkey.result, state])
<button
disabled={passkey.busy}
onClick={() => passkey.signIn({ redirectUri, codeChallenge })}
>
Use a passkey
</button>This hook excerpt uses the backend-generated challenge, state, and postPasskeyCode helper from the passkey handoff. The hook stores outcomes in result; handle non-complete statuses in your form too. Start autofill from an effect and mark the email field autocomplete="username webauthn" while active. The hook aborts on unmount. A dismissed ceremony returns null and records its reason in error; avoid showing an error for normal cancellation. Reasons include aborted, not_allowed, unsupported, and failed. Transport failures still throw.
usePasskeys() provides passkeys, refresh, register, rename, remove, supported, busy, and error. As with useMfa, pass a step-up { code } to mutations or handle StepUpRequiredError. For passkey proof, call useStepUp().verifyWithPasskey('passkey_manage').
useUser() returns the current user or null. useSession() returns the client’s session state:
const user = useUser()
// user?.id, user?.email, user?.aal, user?.isImpersonated
const { isLoaded, isSignedIn, session, aal, isImpersonated } = useSession()These values are decoded for rendering. Your backend must verify the token before using its claims for authorization. Call users.session.signOut(scope) on the client to sign out.
Clearly display isImpersonated when true so users can see that an operator is acting on the account.
The current store is synchronous, so isLoaded is always true. Keep checking it in guards to remain compatible with a future asynchronous store.
SignedIn and SignedOut render children only in the matching session state:
<SignedOut><SignInForm /></SignedOut>
<SignedIn>Signed in as {user?.email}</SignedIn>Errors
Handle flow outcomes separately from request failures.
A wrong password, expired code, incorrect one-time code, or rejected sign-up returns a flow result. Branch on result.error.code:
const result = await handle.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 'code_expired': return say('That code has expired. Send a new one.')
case 'resend_too_soon': return say('Wait a moment before asking for another.')
default: return say('We could not sign you in.')
}
}Include a fallback for unknown codes. New codes can be added without redefining existing ones.
Browser request failures throw UsersClientError with code and status. Subclasses include RateLimitedError, NotFoundError, NetworkError, StepUpRequiredError, and UsersApiError:
import { RateLimitedError, UsersClientError } from '@lepsto/sdk/users'
try {
await users.signIn.create({ identifier: email })
} 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 the sign-in service.')
} 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 — straight from Retry-After, and null when the server sent none — rather than retrying in a loop. |
401 | The key is wrong, missing, or being used on the wrong side. | Read the message: it carries a factual hint about 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 | Service failure. | Retry with backoff. Existing sessions keep working: your backend verifies access tokens locally, so it does not depend on us being reachable. |
A revoked session causes checked backend verification to reject with session_revoked. Local verification rejects once the access token expires. Middleware returns 401 rather than running a handler with null claims. When the browser learns the session has ended, it changes to signed-out state and fires onSessionChange.
Next steps
- Sessions and tokens for exchange and verification contracts.
- Authentication flows for method-specific steps.
- Authentication configuration for keys, origins, and lifetimes.
- MFA and Passkeys for self-service settings.