Skip to content

Users

Sign in your first end-user

Take an empty product to a signed-in end-user your own backend can read.

This is the shortest path from an empty setup to a first end-user signed in to your product. It takes seven steps and ends with your own backend reading the id of a signed-in person.

Goal

A working sign-in: a user creates an account, your backend redeems the single-use code the browser was handed, and a route of yours answers with that user’s stable id.

Prerequisites

  • A Lessly product. If you do not have one yet, set up your team creates one.
  • A frontend and a backend you can run. The example is React on http://localhost:3000 and Node on http://localhost:4000; nothing in the flow is specific to either.
  • Node and npm, to install the three packages.

Step 1 — Add Lessly Users to your product

Installing creates the product’s authentication configuration, with defaults you change later, and issues the product’s keys.

Step 2 — Choose a sign-in method

A new product starts with password sign-in and public sign-up: anyone with an email address may create an account, and the address gets a verification email. That is enough to finish this tutorial.

Email one-time codes, magic links, Google, GitHub, a second factor and invite-only sign-up are all switched on later in Configure authentication, without touching your code.

Step 3 — Allow your origin and your callback

Browser calls are accepted only from origins you list, which is what makes the publishable key in step 4 harmless to anyone who copies it out of your bundle. Two entries go in: the exact origin your frontend runs on, and the callback address the single-use code is redeemed against — a route on your own backend, which you write in step 6.

For the example setup those are:

origin        http://localhost:3000
redirect URI  http://localhost:4000/auth/callback

Step 4 — Get the keys

Your product has two keys and they are not interchangeable.

KeyPrefixWhere it belongs
Publishable keyupk_Your frontend. Not a secret; it identifies the product and is protected by the origin allowlist.
Server keyusk_Your backend only. A secret, shown once when it is created — store it then.

Put them in your environment:

# frontend
PUBLIC_LESSLY_PRODUCT_ID=prd_...
PUBLIC_LESSLY_USERS_PUBLISHABLE_KEY=upk_...

# backend
LESSLY_PRODUCT_ID=prd_...
LESSLY_USERS_SERVER_KEY=usk_...

Both libraries take the product id alongside the key: it is what they derive the address of Lessly Users from, and it is the aud every token carries.

If the server key ever reaches a browser bundle, revoke it and create a new one. Both keys rotate without downtime.

Step 5 — Render sign-in

Install the browser packages:

npm install @lessly/users-client @lessly/users-react

Construct one client for the application and pass it to the provider. The provider takes the client itself, not a key, so there is one session state for the whole app:

// users-client.ts
import { createUsersClient } from '@lessly/users-client'

export const CALLBACK = 'http://localhost:4000/auth/callback'

export const users = createUsersClient({
  productId: import.meta.env.PUBLIC_LESSLY_PRODUCT_ID,
  publishableKey: import.meta.env.PUBLIC_LESSLY_USERS_PUBLISHABLE_KEY,
})
import { UsersProvider } from '@lessly/users-react'
import { users } from './users-client'

export function App({ children }) {
  return <UsersProvider client={users}>{children}</UsersProvider>
}

The hooks are headless: you write the form, and Lessly Users runs the flow behind it. A minimal password sign-in is one call to start the attempt and one to submit the password. Passing redirectUri — the callback you allowed in step 3, spelled exactly — is what puts the flow on the code handoff: the completion carries a single-use code instead of tokens, and the attempt carries the PKCE verifier that proves the code is being redeemed for the browser that started the flow.

import { useState } from 'react'
import { isCodeHandoff } from '@lessly/users-client'
import { useSignIn } from '@lessly/users-react'
import { CALLBACK } from './users-client'

function SignInForm({ onSignedIn }) {
  const { create, attempt, error } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  async function submit(event) {
    event.preventDefault()
    const flow = await create({ identifier: email, redirectUri: CALLBACK })
    const result = await attempt({ strategy: 'password', password })

    if (isCodeHandoff(result)) {
      // Step 6 redeems these two. The code is single-use and lives at most a minute.
      const response = await fetch(CALLBACK, {
        method: 'POST',
        credentials: 'include',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ code: result.code, codeVerifier: flow.pkceVerifier }),
      })
      onSignedIn(await response.json())
    }
  }

  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">Sign-in failed: {error}</p>}
      <button type="submit">Sign in</button>
    </form>
  )
}

export function Page() {
  const [session, setSession] = useState(null)
  if (session === null) return <SignInForm onSignedIn={setSession} />
  return <p>Signed in. Step 7 reads the user back from your own backend.</p>
}

error is the flow’s error code as a string — invalid_credentials covers a wrong password and an unknown address alike, on purpose.

A completed flow hands the browser no token. It hands it a code, and your backend turns that code into a session in the next step.

Step 6 — Redeem the code on your backend

Install the server library:

npm install @lessly/users

The library holds your server key and does the three things the key authorises: exchanging a code, refreshing a session, and verifying an access token. It mounts no routes and sets no cookies — the callback is a route you write.

import express from 'express'
import cors from 'cors'
import { createUsersClient } from '@lessly/users'

const CALLBACK = 'http://localhost:4000/auth/callback'

export const users = createUsersClient({
  productId: process.env.LESSLY_PRODUCT_ID,
  serverKey: process.env.LESSLY_USERS_SERVER_KEY,
})

const app = express()
app.use(cors({ origin: 'http://localhost:3000', credentials: true }))
app.use(express.json())

// Your own store, keyed by the session id. A refresh token never leaves the backend.
const refreshTokens = new Map()

app.post('/auth/callback', async (req, res) => {
  const bundle = await users.exchangeCode(req.body.code, req.body.codeVerifier, {
    redirectUri: CALLBACK,
  })

  refreshTokens.set(bundle.session.id, bundle.refreshToken)

  res.json({
    accessToken: bundle.accessToken,
    expiresIn: bundle.expiresIn,
    sessionId: bundle.session.id,
  })
})

redirectUri must be the same address the browser opened the flow with, and both must be spelled exactly as you allowed them in step 3. A code is single-use and expires in under a minute (codeExpiresIn says how long it had).

The bundle is the whole session: accessToken, refreshToken, expiresIn and the session record. What you do with it is yours — a cookie on your own domain, a row in your own store, a response to a mobile client. Keep the refresh token on the backend and call users.refresh(refreshToken) when the access token runs out; every refresh returns a new refresh token and kills the one you spent, so store the new value before you use it again.

Step 7 — Read the user on your own routes

Your backend now knows who is calling. expressMiddleware reads the Bearer token off the request, verifies it, and puts the claims on req.auth. Verification is local: the library checks the signature against your product’s published keys and caches them for ten minutes, so an authenticated request costs you no network call.

import { expressMiddleware } from '@lessly/users'

app.use('/api', expressMiddleware(users))

app.get('/api/me', (req, res) => {
  res.json({ userId: req.auth.sub, email: req.auth.email })
})

A request without a valid token never reaches the handler — the middleware answers 401 with { error, hint } and does not call through. So inside /api/me the claims are always there.

The frontend sends the access token step 6 handed back:

const response = await fetch('http://localhost:4000/api/me', {
  headers: { authorization: `Bearer ${session.accessToken}` },
})

req.auth.sub is the stable user id from Lessly Users. Store that in your own tables — not the email address. req.auth.sid is the session, and req.auth.email is there only when the address is verified.

What you just did

Sign up a first user, then look for them in the management App: the record exists, the session you just created is listed against it, and the sign-in appears in their security history. You now have authentication whose session your own backend holds, and a directory holding everyone who uses it.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect