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
| Layer | Technology |
|---|---|
| Auth engine | Better Auth (on the backend) |
| Database adapter | Drizzle ORM (PostgreSQL) via @workspace/db |
| Client | better-auth/react (frontend, over the proxy) |
| Plugins | Two-Factor Authentication |
Environment Variables
Add the following to apps/api/.env:
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_URLis 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/
├── 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 tablesServer Configuration
The auth instance lives in apps/api/src/auth.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-pluginsregions let installable features add Better Auth server plugins without overwriting this file. The client counterpart (apps/web/lib/auth-client.ts) exposesclient-imports/client-plugins, and extrausercolumns can be contributed through theuser-fieldsregion ofpackages/db/src/schema.ts. See Packages for the full marker catalog.
It is mounted as a catch-all in apps/api/src/app.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:
import { signIn, signOut, useSession } from "@/lib/auth-client"Sign in with email
const { error } = await signIn.email({ email, password, callbackURL: "/dashboard",})Sign in with Google
await signIn.social({ provider: "google", callbackURL: "/dashboard",})Access the current session
"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:
pnpm --filter @workspace/db db:pushProduction Checklist
The defaults are tuned for local development. Before deploying:
- Trusted origins — set
BETTER_AUTH_TRUSTED_ORIGINSinapps/api/.envto the public origin(s) of the frontend (comma-separated). When unset, auth falls back tohttp://localhost:3000andhttp://localhost:1212, and every OAuth/callback validation fails in production. - CORS — set
CORS_ORIGINinapps/api/.envthe same way; the Hono CORS policy defaults tohttp://localhost:3000. - 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. - 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
emailVerificationsettings toapps/api/src/auth.tsbefore going public. - Rate limiting — enabled at all times and persisted in the
rate_limittable (survives restarts); sign-in/sign-up allow 3 attempts per 10 seconds per client IP. Verified locally: through the Next.js/apirewrite 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 sendx-forwarded-for(standard for Vercel/nginx/Cloudflare) so per-IP attribution resumes; if your runtime needs different headers, setadvanced.ipAddress.ipAddressHeadersinapps/api/src/auth.ts.
Note on the feature manager: The
authfeature package (packages/feature-manager/features/auth) installs this decoupled setup (apps/api/src/auth.tson the backend,apps/web/lib/auth-client.tson the frontend, and the auth pages).