Skip to content

Sign in your first end-user

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

Set up sign-in for your product in seven steps, then check that your backend can read the signed-in user’s id.

Goal

A working sign-in: a user creates an account, your backend redeems the single-use code the flow produced, 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 React or to Node — the same seven steps hold for any frontend and any backend language.
  • Node and npm, to install the three packages.

Step 1 — Add Lessly Users to your product

Install Lessly Users for your product, or ask an agent to do it. Installation creates the product’s authentication configuration with defaults you can then change, 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.

Everything else — email one-time codes, magic links, Google and GitHub, a second factor, passkeys, invite-only sign-up — is 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, so the publishable key in step 4 is useless to anyone who copies it out of your bundle. Add the exact origin your frontend runs on:

http://localhost:3000

While you are there, allow the address the user is returned to after signing in. It is a route on your own backend, and step 6 mounts it:

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. It is shown once when it is created — store it then.

Put them in your environment:

# frontend
PUBLIC_LESSLY_USERS_PUBLISHABLE_KEY=upk_...

# backend
LESSLY_USERS_SERVER_KEY=usk_...

If the server key ever reaches a browser bundle, revoke it and create a new one; both keys can be rotated without downtime.

Step 5 — Render sign-in

Install the React bindings:

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

You need @lessly/users-react 0.8.0 or later and @lessly/users-client 0.7.0 or later for the sign-in below.

Build the client with your product id and publishable key, and hand it to the provider:

import { createUsersClient } from '@lessly/users-client'
import { UsersProvider } from '@lessly/users-react'

const users = createUsersClient({
  productId: process.env.PUBLIC_LESSLY_PRODUCT_ID,
  publishableKey: process.env.PUBLIC_LESSLY_USERS_PUBLISHABLE_KEY,
})

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:

import { useState } from 'react'
import { useSignIn, SignedIn, SignedOut, useUser } from '@lessly/users-react'

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

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

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

    if (result.status === 'complete') {
      // Hand both halves to your own backend, which redeems them in step 6.
      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)} />
      <button type="submit">Sign in</button>
    </form>
  )
}

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

A completed flow does not hand the browser a token. It answers with a single-use code for the callback you allowed in step 3, which your backend redeems in the next step. That is what keeps the session in a cookie on your own domain instead of in browser storage.

On this path the browser mints the PKCE pair and hands the verifier back on handle.pkceVerifier, which is why the form posts both halves to a route of its own.

Or render no sign-in UI at all

If you would rather write no sign-in UI at all, render the prebuilt <SignIn/> instead of a form of your own. It is the same package and the same flow, with every screen already built — the one-time code, the magic-link confirmation, the second factor, lockout — and a theme prop for the styling:

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

export function Page({ codeChallenge, state }) {
  const user = useUser()
  return (
    <>
      <SignedOut>
        <SignIn
          redirectUri="http://localhost:4000/auth/callback"
          codeChallenge={codeChallenge}
          state={state}
        />
      </SignedOut>
      <SignedIn>
        Signed in as {user?.email} <UserButton />
      </SignedIn>
    </>
  )
}

redirectUri is the callback you allowed in step 3, spelled exactly, and the component form-POSTs the single-use code to it.

state is your own value — here a CSRF token — echoed back to the callback.

So the two paths differ in one place only: with your own form the browser mints the pair, and with <SignIn/> your backend does. Use the client libraries covers <SignIn/>, <SignUp/>, <UserButton/> and <UserProfile/> in full.

Step 6 — Redeem the code on your backend

Install the server library:

npm install @lessly/users

Your backend does two things: mint the PKCE pair before the sign-in page renders, and exchange the code when it comes back. The verifier stays here for the whole round trip — that is what makes an intercepted code useless.

import { createHash, randomBytes } from 'node:crypto'
import express from 'express'
import { createUsersClient } from '@lessly/users'

const app = express()

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

const CALLBACK = 'http://localhost:4000/auth/callback'
const base64url = (bytes) => bytes.toString('base64url')

app.get('/sign-in', (req, res) => {
  const verifier = base64url(randomBytes(32))
  req.session.pkceVerifier = verifier

  // Only the challenge crosses into the page.
  res.render('sign-in', {
    codeChallenge: base64url(createHash('sha256').update(verifier).digest()),
    state: req.session.csrfToken,
  })
})

app.post('/auth/callback', express.urlencoded({ extended: false }), 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
  // `bundle.accessToken` and `bundle.refreshToken` are yours to put in a
  // first-party cookie on your own domain.
  res.redirect('/')
})

If you wrote your own form in step 5 instead, it posts the code and the browser-minted verifier to a route of yours, and that route makes the same exchangeCode call — there is no /sign-in minting step, because the browser did it.

The code is single-use and lives about a minute.

Putting the bundle in a cookie is the one part @lessly/users does not do for you: it ships no cookie helper, so the attributes are yours to set. The __Host- prefix is what makes the cookie unambiguously yours, and a browser enforces it — Secure, Path=/, and no Domain attribute at all. Add HttpOnly so no script can read it, and a SameSite (Lax is enough here, because the callback is a top-level POST to your own origin). Sessions and tokens covers what goes in it and how it is refreshed.

Step 7 — Read the user on your own routes

Mount the middleware on the routes that carry an access token. It verifies the token and hangs the claims on req.auth:

import { expressMiddleware } from '@lessly/users'

app.use(expressMiddleware(users))

app.get('/api/me', (req, res) => {
  if (!req.auth?.sub) return res.status(401).end()
  res.json({ userId: req.auth.sub, email: req.auth.email })
})

Verification is local — the library checks the signature against your product’s published keys and caches them, so an authenticated request costs you no network call.

req.auth.sub is the stable id from Lessly Users. Store that in your own tables — not the email address.

What you just did

Sign up a first user, then look for them on Users → Users in the management App. Opening their row shows the record: the identifiers, the session you just created, and the sign-in in the audit trail.

Next steps

  • Configure authentication: turn on email codes, magic links, Google, GitHub or a second factor, set the password policy, and change how long a session lives.
  • Read the token contract: the token contract in full, refresh, and revoking a session immediately rather than within the access token’s lifetime.
  • Use the client libraries: everything the three packages expose.
  • Manage your end-users: find the user you just created, and everything you can do to the record.
Was this page helpful?
Esc

Start typing to search the docs.

navigateselect