# Sign in your first end-user

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](/get-started/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.

### UI

In the management App (`app.lessly.com`), add Users to your product. Installing creates the authentication configuration and issues the keys.

### MCP

Ask your agent:

```text
Add Users to the Acme product.
```

The agent needs admin rights on the product.

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

### UI

In the management App, open your product's authentication configuration and confirm password sign-in is on and sign-up is public. Leave everything else as it is.

### MCP

Ask your agent:

```text
Show me the authentication settings for Acme — which sign-in methods are on, and who may sign up?
```

The agent needs write access to the product's authentication configuration.

Email one-time codes, magic links, Google, GitHub, a second factor and invite-only sign-up are all switched on later in [Configure authentication](/ship/users/configuration), 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:

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

### UI

In the management App, add the first to your product's allowed origins and the second to its redirect URIs.

### MCP

Ask your agent:

```text
For Acme, allow that origin and that redirect URI.
```

The agent needs write access to the product's authentication configuration.

> **WARNING**
> Both lists are matched exactly — scheme, host and port. There is no wildcard and no prefix matching. Add your production entries the same way when you deploy, and remove an entry as soon as the environment it belonged to is gone.

## Step 4 — Get the keys

Your product has two keys and they are not interchangeable.

| Key | Prefix | Where it belongs |
|---|---|---|
| Publishable key | `upk_` | Your frontend. Not a secret; it identifies the product and is protected by the origin allowlist. |
| Server key | `usk_` | Your backend only. A secret, shown once when it is created — store it then. |

### UI

In the management App, copy the publishable key from your product's authentication configuration, then create a server key and copy it while it is on screen.

### MCP

Ask your agent:

```text
Create a server key for Acme and give me the publishable key.
```

The agent needs write access to the product's authentication configuration. The server key is returned once and is not retrievable afterwards.

Put them in your environment:

```bash
# 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:

```bash
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:

```ts
// users-client.ts

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,
})
```

```tsx

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.

```tsx

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.

> **NOTE**
> `useUser()`, `useSession()` and the `SignedIn` / `SignedOut` guards read the **browser client's own** session, which stays empty on the code handoff — the session belongs to your backend from step 6 on. A page that wants the guards to light up adopts the bundle with `users.session.setTokens(bundle)`, which means handing the browser a refresh token; this tutorial keeps that token on the backend instead.

> **WARNING**
> The prebuilt `<SignIn/>` from `@lessly/users-react` draws every screen for you, and **it cannot reach step 6 as things stand** — which is why this tutorial writes the form. Given `redirectUri` the component form-POSTs the single-use code to that address as a field named `code` and nothing else; given `onComplete` it hands you the `FlowResult`, which carries the code and no more. The PKCE verifier is on the flow handle inside the component, reachable from neither shape, and the exchange refuses a code without it. Reach for `<SignIn/>` on a flow your own backend does not redeem; use the form above when it does.

## Step 6 — Redeem the code on your backend

Install the server library:

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

```ts

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.

```ts

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:

```ts
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](/ship/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

- [Configure authentication](/ship/users/configuration): turn on email codes, magic links, Google, GitHub or a second factor, and change how long a session lives.
- [Read the token contract](/ship/users/sessions-and-tokens): refresh, and revoking a session immediately rather than within the access token's lifetime.
- [Use the client libraries](/ship/users/client-libraries): everything the three packages expose.
- [Manage your end-users](/ship/users/user-management): find the user you just created, and everything you can do to the record.
