Skip to content

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

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.

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:

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:

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:

import {
  isConditionalMediationAvailable,
  isPasskeySupported,
  isPlatformAuthenticatorAvailable,
} from '@lessly/users-client'

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:

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:

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

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>
    </>
  )
}

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:

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.

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

import { SignIn, SignedIn, SignedOut, UserButton } from '@lessly/users-react'

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.

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.

import { createHash, randomBytes } from 'node:crypto'

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:

PropDefaultWhat it does
chrome'card''none' drops the card and draws on your own surface
heading'Two-step verification' / 'Passkeys'The heading above the section
onChangeCalled with the factors, or the passkeys, after a mutation
theme, classNameThe 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 and Add 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:

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

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