Authentication

Email/password and Google OAuth authentication powered by Better Auth, running on the backend

Authentication runs entirely on the backend (apps/api). Better Auth handles email/password sign-up, Google OAuth, and two-factor authentication (2FA), and persists sessions through the Drizzle adapter. The frontend never touches the database or a server auth instance — it uses the Better Auth client over the /api proxy.

Stack

LayerTechnology
Auth engineBetter Auth (on the backend)
Database adapterDrizzle ORM (PostgreSQL) via @workspace/db
Clientbetter-auth/react (frontend, over the proxy)
PluginsTwo-Factor Authentication

Environment Variables

Add the following to apps/api/.env:

bash
PORT=4000BETTER_AUTH_URL=http://localhost:1212BETTER_AUTH_SECRET=<generate with: openssl rand -base64 32>DATABASE_URL=postgresql://postgres:postgres@localhost:5432/celestiaGOOGLE_CLIENT_ID=your-google-client-idGOOGLE_CLIENT_SECRET=your-google-client-secret

BETTER_AUTH_URL is the public URL the browser uses to reach auth. Because the frontend proxies /api/* to the backend, the public URL is the frontend (http://localhost:1212).

File Overview

apps/api/src/Directory Structure
apps/api/src/
├── auth.ts            # Better Auth instance (Drizzle adapter)
└── app.ts             # Mounts /api/auth/* via auth.handler

apps/web/
├── lib/auth-client.ts # Better Auth client (browser)
└── app/(auth)/
    ├── sign-in/page.tsx
    └── sign-up/page.tsx

packages/db/src/
└── schema.ts          # user / session / account / verification / twoFactor tables

Server Configuration

The auth instance lives in apps/api/src/auth.ts:

ts
import { betterAuth } from "better-auth"import { drizzleAdapter } from "better-auth/adapters/drizzle"import { twoFactor } from "better-auth/plugins"// feature-manager:auth-imports:begin// feature-manager:auth-imports:end
import { db } from "@workspace/db"
export const auth = betterAuth({  database: drizzleAdapter(db, { provider: "pg" }),  emailAndPassword: { enabled: true },  socialProviders: {    google: {      clientId: process.env.GOOGLE_CLIENT_ID!,      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,    },  },  plugins: [    twoFactor({ issuer: "Celestia" }),    // feature-manager:auth-plugins:begin    // feature-manager:auth-plugins:end  ],  trustedOrigins: ["http://localhost:1212", "http://localhost:3000"],})

Extension points: the auth-imports / auth-plugins regions let installable features add Better Auth server plugins without overwriting this file. The client counterpart (apps/web/lib/auth-client.ts) exposes client-imports / client-plugins, and extra user columns can be contributed through the user-fields region of packages/db/src/schema.ts. See Packages for the full marker catalog.

It is mounted as a catch-all in apps/api/src/app.ts:

ts
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw))

Client Usage

The frontend client (apps/web/lib/auth-client.ts) talks to auth through the same-origin proxy:

ts
import { signIn, signOut, useSession } from "@/lib/auth-client"

Sign in with email

ts
const { error } = await signIn.email({  email,  password,  callbackURL: "/dashboard",})

Sign in with Google

ts
await signIn.social({  provider: "google",  callbackURL: "/dashboard",})

Access the current session

tsx
"use client"import { useSession } from "@/lib/auth-client"
export function Profile() {  const { data, isPending } = useSession()
  if (isPending) return <p>Loading...</p>  if (!data) return <p>Not signed in</p>
  return <p>Hello, {data.user.name}</p>}

Database Setup

The auth tables live in packages/db. Push the schema:

bash
pnpm --filter @workspace/db db:push

Production Checklist

The defaults are tuned for local development. Before deploying:

  1. Trusted origins — set BETTER_AUTH_TRUSTED_ORIGINS in apps/api/.env to the public origin(s) of the frontend (comma-separated). When unset, auth falls back to http://localhost:3000 and http://localhost:1212, and every OAuth/callback validation fails in production.
  2. CORS — set CORS_ORIGIN in apps/api/.env the same way; the Hono CORS policy defaults to http://localhost:3000.
  3. Secret — generate a strong BETTER_AUTH_SECRET (openssl rand -base64 32). The server logs a warning when it is missing, set to the documented placeholder, or shorter than 32 characters.
  4. Email verification — accounts created via email/password are active immediately; no email sender is configured in the starter. If you need verified accounts, add an email plugin (e.g. Resend) and emailVerification settings to apps/api/src/auth.ts before going public.
  5. Rate limiting — enabled at all times and persisted in the rate_limit table (survives restarts); sign-in/sign-up allow 3 attempts per 10 seconds per client IP. Verified locally: through the Next.js /api rewrite no client IP reaches the API, so dev traffic shares one bucket per path (harmless — every local client is the same machine). In production your edge proxy must send x-forwarded-for (standard for Vercel/nginx/Cloudflare) so per-IP attribution resumes; if your runtime needs different headers, set advanced.ipAddress.ipAddressHeaders in apps/api/src/auth.ts.

Note on the feature manager: The auth feature package (packages/feature-manager/features/auth) installs this decoupled setup (apps/api/src/auth.ts on the backend, apps/web/lib/auth-client.ts on the frontend, and the auth pages).