Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Authentication

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

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.

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.

@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

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.

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:

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

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

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

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.

  • Database — the schema these plugins generate
  • Authorization — the roles a session is checked against
  • Billing — the plugin that runs on the same instance
  • Mail — where the sign-in and invitation emails are written

Last updated on September 22, 2026

Was this page helpful?