Skip to content

Sign in your first end-user

Set up browser sign-in and exchange a one-time code on your backend.

Add sign-in to your application, exchange its handoff code on your backend and verify the resulting user ID. The seven steps below cover a custom React form and the prebuilt sign-in component.

Goal

By the end, an account can sign in and your /api/me route can return its stable user ID.

Prerequisites

  • A Lepsto product; set up your team if you need one.
  • A frontend and backend. The examples use React at http://localhost:3000 and Node at http://localhost:4000; replace these origins and callbacks with yours. The same flow works with other languages and frameworks.
  • Node 20 or later, npm and @lepsto/sdk 1.0.0 or later.
  • Your application’s session storage, CSRF protection and rendering setup. The Express snippets show the authentication integration, not a complete server.

Step 1 — Add Lepsto Users to your product

Install Lepsto Users for the product, or ask your agent to install it. This creates its default authentication configuration and Users keys. You will create the backend product public key separately in step 4.

Step 2 — Choose a sign-in method

Keep the default password method and public sign-up for this tutorial. A new user can create an account with an email address and password, then receive a verification email.

You can later configure email codes, magic links, Google, GitHub, MFA, passkeys or invite-only sign-up in authentication configuration.

Step 3 — Allow your origin and your callback

Set the frontend’s exact origin in allowedOrigins through the product configuration API or your agent. Browser requests using the publishable key must come from an allowed origin:

http://localhost:3000

Set redirectUriAllowlist to include your backend callback, which step 6 implements. Send every entry you want to keep when replacing either list:

http://localhost:4000/auth/callback

Step 4 — Get the keys

Use a publishable key in the frontend and a product public key on the backend. Retrieve the publishable key from Users. Create the route-scoped backend key through the management API:

Key
Prefix
Where to get it
Where it belongs
Publishable key
upk_
Users → Keys
Frontend; identifies the product, with browser calls checked against allowed origins
Product public key
lpk_
Management API for a route-scoped key; listed in Product → Settings → Public Access
Backend secret; shown only at creation

As an Owner or Admin, use the management API to create a public key with this route in its scope:

{ "extension": "users", "method": "POST", "path": "/code/exchange" }

The Public Access UI selects all or selected toolkits, not individual routes. It lists a route-scoped key as limited to specific routes. Saving that key in the toolkit-level Edit scope editor replaces its route scope and widens access.

Copy the key returned at creation and set the environment variables below, replacing the sample values. Keep this route-scoped key separate from keys used by other services:

# frontend
PUBLIC_LEPSTO_USERS_PUBLISHABLE_KEY=upk_...

# backend
LEPSTO_USERS_PUBLIC_KEY=lpk_...

Allow about 30 seconds for the new public key to propagate before testing. If it leaks into a browser bundle, revoke it in Public Access and create a replacement. To replace a key during normal operation, deploy a new key before revoking the old one. Legacy usk_ keys no longer authenticate code exchange.

Step 5 — Render sign-in

Install the SDK:

npm install @lepsto/sdk

Use SDK 1.0.0 or later. Add the optional react and react-dom peer dependencies for the React imports. For TypeScript, use moduleResolution: node16, nodenext, or bundler with the current package’s ESM or CommonJS declarations; see client libraries.

The server import holds the product public key. Browser code uses the publishable key through @lepsto/sdk/users, with hooks from @lepsto/sdk/users/react and components from @lepsto/sdk/users/react/ui.

Construct a browser client with your product ID and publishable key, then provide it to your React tree:

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 }) {
  return <UsersProvider client={users}>{children}</UsersProvider>
}

For a custom form, use the hooks to start an attempt and submit the password:

import { useState } from 'react'
import { useSignIn, SignedIn, SignedOut, useUser } from '@lepsto/sdk/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()
    const handle = 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>
    </>
  )
}

Because this attempt sets redirectUri, completion returns a single-use code. Your backend exchanges it for tokens and stores the session on your own domain.

In this custom-form path, the browser creates the PKCE pair. handle.pkceVerifier supplies its private half, which the form sends with the code to your /auth/complete endpoint.

Or render no sign-in UI at all

To use ready-made screens, replace the custom form with <SignIn/> from @lepsto/sdk/users/react/ui. It handles email codes, magic-link confirmation, second factors and lockout, and accepts a theme for styling:

import { SignedIn, SignedOut, useUser } from '@lepsto/sdk/users/react'
import { SignIn, UserButton } from '@lepsto/sdk/users/react/ui'

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

Use the exact callback from your allowlist as redirectUri. The component sends the handoff code there by form POST.

state is a value your callback validates. The example uses the application’s CSRF token.

The two paths differ in PKCE ownership: the custom hook creates its pair in the browser; the redirecting prebuilt component uses a pair created by your backend. See client libraries for the complete component contracts.

Step 6 — Redeem the code on your backend

Import the server client from the same package. Its publicKey is the route-scoped lpk_ secret from step 4.

For the prebuilt component, create a PKCE verifier before rendering sign-in. Save it in the application’s server session and send only its SHA-256 challenge to the page. At the callback, validate state and exchange the code with that saved verifier:

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

const app = express()

const users = createUsersClient({
  productId: process.env.LEPSTO_PRODUCT_ID,
  publicKey: process.env.LEPSTO_USERS_PUBLIC_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('/')
})

For the custom form, implement /auth/complete instead. It receives the code and browser-generated verifier from step 5 and calls the same exchangeCode method with the allowlisted callback. That path does not use the /sign-in PKCE-generation route.

Exchange the code promptly: it is single-use and lasts about a minute.

Write the session storage and refresh handling in your application; the SDK does not set cookies. For a first-party session cookie, use a __Host- name, Secure, Path=/, no Domain, HttpOnly and an appropriate SameSite policy. SameSite=Lax fits the same-site callback in this example. Add CSRF validation to cookie-authenticated state changes. See sessions and tokens.

Step 7 — Read the user on your own routes

Protect API routes that receive an access token with expressMiddleware. It reads the bearer token and attaches verified claims to req.auth; it does not extract or refresh your custom session cookie. Use your session layer to supply the access token, or use requireAuth with a token extractor as described in client libraries.

import { expressMiddleware } from '@lepsto/sdk/users/server'

app.use('/api', 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 })
})

The SDK verifies signatures locally against cached product signing keys. Fetching or refreshing that key cache requires a request; ordinary verification uses the cached keys.

Store req.auth.sub as the user reference in your database. Email is mutable and is included only when verified.

What you just did

  1. Create a test account and complete sign-in through your application.
  2. Call /api/me with its access token and confirm the returned userId. A request without a valid token should receive 401.
  3. Open the account in Users → Users. Confirm its identifiers, new session and sign-in audit entry match the test.

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 four subpaths expose, and the rule that picks between them.
  • 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.

↑↓navigate↵select