---
title: Authentication
description: Sign-in, sessions, organizations and onboarding, built on Better Auth and set up once for the whole workspace.
---

`@zap-ts/authentication` is the one place that sets up Better Auth. It builds
the server instance and the browser client. It also holds the helpers that read
a session, run the onboarding, and list the invitations of an organization.
Apps import from this package. Nothing outside it calls `betterAuth` itself.

Every user owns an organization. The app creates one right after sign-up, and
the session starts on it. So the rest of the app can always read an
organization from a session.

## The instance of a request

```ts
import { createAuth } from "@zap-ts/authentication/auth";
import { env as cloudflareEnv } from "cloudflare:workers";

const auth = createAuth(cloudflareEnv.HYPERDRIVE.connectionString);
const session = await auth.api.getSession({ headers: request.headers });
```

Build one instance per request. The connection string is the address of the
database. It comes from the Hyperdrive binding of that request.

`createAuth` reads the keys from the environment. `createAuthWith` takes the
keys as arguments instead. Use it for tools that have no environment. The
schema generator is the one tool that does this.

The package stores its data with `@better-auth/drizzle-adapter`, on top of
`createAuthDatabase` from [Database](/packages/database).

## What is turned on

Users can sign in with an email and a password, or with Google. On top of that,
the instance loads the Better Auth plugins for organizations, two-factor
sign-in, usernames, magic links, email codes, last login method, admin, and
TanStack Start cookies.

Four more plugins come from their own packages:

- `@better-auth/passkey` — sign in with a passkey. A passkey is a key stored on
  your device, so you do not type a password.
- `@better-auth/api-key` — API keys.
- `@better-auth/i18n` — English and French.
- `@better-auth/drizzle-adapter` — the Postgres storage.

Six emails go out from here: reset password, verify email, magic link, sign-in
code, delete account, and organization invitation. Each one uses a template
from [Mail](/packages/mail).

`@zap-ts/authentication/auth` also exports `getAuthenticatorName`. It turns the
AAGUID of a passkey into a readable device name. An AAGUID is an id that says
which model of device made the passkey. You get `undefined` when the model is
unknown.

## The browser client

```tsx
import { authClient } from "@zap-ts/authentication/client";

const SignOutButton = () => (
  <button onClick={() => authClient.signOut()} type="button">
    Sign out
  </button>
);
```

`authClient` talks to `/api/auth`, so you do not give it a base URL. It loads
the same plugins as the server. Two-factor sign-in sends the user to
`/two-factor`.

This module also exports two types for your UI: `Session` and `Organization`.

## Reading a session

`@zap-ts/authentication/session` holds the reads the app makes.

```ts
const session = yield * readSession(auth, headers);
```

`readSession` does one thing that `auth.api.getSession` does not. The session
made at sign-up has no active organization yet, because the app creates the
organization just after it. `readSession` sets it on the next read.

`readActiveOrganizationId` is the same read. It gives you only the id.

Some routes also need the role of the user. Use this:

```ts
const membership = await getActiveMembership(connectionString, request.headers);

if (!membership) {
  return new Response(m.api_forbidden(), { status: 403 });
}
```

It gives you the organization id, the role of the signed-in member, and their
email. You get `null` in three cases: nobody is signed in, no organization is
active, or the role is not one that
[Authorization](/packages/authorization) knows.

`isAdmin` is a separate check. It reads the `role` field on the user row. It
says whether that user may open the admin app.

## Onboarding

`@zap-ts/authentication/onboarding` works out what the onboarding still has to
ask. It also closes the onboarding.

```ts
const { askName, skip } = yield * readOnboardingState(auth, headers);
```

Google gives you a name. So the name field only shows up when the sign-up left
the name empty.

A member who joined through an invitation is asked nothing about the
organization. That check runs through `organizationPolicy`.

`completeOnboarding` saves both names and closes the onboarding in one call. So
a failure never saves the user's name and loses the organization name.
`skipOnboarding` only closes the onboarding.

The `onboardingCompletedAt` field is set with `input: false`. That means only
the server can write it.

## Invitations

```ts
import { listPendingInvitations } from "@zap-ts/authentication/invitations";

const page =
  yield *
  listPendingInvitations(connectionString, "org_123", {
    limit: 25,
    offset: 0,
    sortByEmail: true,
    direction: "asc",
  });
```

Better Auth returns every invitation of an organization at once, whatever its
status. That is too many. So this function reads the `invitation` table
directly. Postgres does the sorting and returns one page at a time.

## Errors

`@zap-ts/authentication/errors` exports two things. `AuthenticationError` is
the error type. `tryAuth` wraps a Better Auth call, so a failure becomes that
error. Every call in this package goes through `tryAuth`.

## Environment

```bash
BETTER_AUTH_SECRET=local_dev_secret_local_dev_secret
BETTER_AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=local_dev_google_client_id
GOOGLE_CLIENT_SECRET=local_dev_google_client_secret
```

The two Google values only turn on Google sign-in. Email and password work
without them. You declare all four in the `.env.schema` at the root of the
repository.

Do you want to sign in with a provider other than Google? See
[Add an OAuth provider](/recipes/custom-oauth-provider).

## Related

- [Database](/packages/database) — the schema these plugins generate
- [Authorization](/packages/authorization) — the roles a session is checked against
- [Billing](/packages/billing) — the plugin that runs on the same instance
- [Mail](/packages/mail) — where the sign-in and invitation emails are written
