Authentication
Better Auth across web, mobile, and API
Better Auth is the only supported authentication framework. Coldstart generates all auth code using Better Auth — there is no option to swap in NextAuth, Clerk, or other providers. This ensures tight integration with Drizzle, Hono middleware, and cross-platform session sharing.
Every project with an API gets Better Auth — email/password, optional OAuth, typed sessions, and auth pages on every platform.
What's generated
apps/api/src/lib/auth.ts — Better Auth config with Drizzle adapter, email/password, OAuth providers, 7-day sessions.
apps/api/src/middleware/session.ts — two middleware:
// Extracts session — sets c.get("user") and c.get("session")
export const sessionMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
c.set("user", session?.user ?? null);
c.set("session", session?.session ?? null);
await next();
});
// Blocks unauthenticated requests
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
const user = c.get("user");
if (!user) return c.json({ error: "Unauthorized" }, 401);
await next();
});apps/api/src/types.ts — typed context so c.get("user") returns { id, email, name }:
export type AppEnv = {
Variables: {
user: { id: string; email: string; name: string } | null;
session: { id: string; userId: string; expiresAt: Date } | null;
};
};Three auth pages under (auth)/:
- Sign in — email/password form + OAuth buttons (Google, GitHub, Apple)
- Sign up — registration with name, email, password
- Forgot password — password reset flow
All are "use client" components with loading states, error handling, and i18n-aware navigation.
src/lib/auth-client.ts — Better Auth client:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001",
});Use authClient.useSession(), authClient.signIn.email(), authClient.signUp.email(), authClient.signOut().
Two auth screens under app/(auth)/:
- Sign in — email/password + social buttons
- Sign up — registration
Both use the Better Auth React Native client at lib/auth-client.ts.
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:3001",
});Database schema
Better Auth requires four tables in your database, auto-generated in packages/db/src/auth-schema.ts:
| Table | Purpose | Key columns |
|---|---|---|
user | User profiles | id, name, email, emailVerified, image |
session | Active sessions | id, token, expiresAt, userId, ipAddress, userAgent |
account | OAuth + credential links | id, accountId, providerId, userId, accessToken, password |
verification | Email verification & password reset tokens | id, identifier, value, expiresAt |
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});This file is a stub generated by Coldstart. When you run the Better Auth CLI (pnpm dlx better-auth generate), it overwrites auth-schema.ts with the canonical version. The stub ensures TypeScript compiles before you run the CLI.
Session management
Sessions are configured in apps/api/src/lib/auth.ts:
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh token once per day
},How sessions work across platforms:
- API — the
sessionMiddlewarereads the session cookie (orAuthorization: Bearerheader) on every request and populatesc.get("user")andc.get("session"). - Web — the Better Auth React client (
authClient.useSession()) manages cookies automatically. Sessions persist across page reloads. - Mobile — the React Native client stores the session token in secure storage. It's sent as a Bearer token in the
Authorizationheader.
Sessions are stored in the session table with IP address and user agent for audit purposes. Expired sessions are cleaned up automatically by Better Auth.
OAuth providers
Select during coldstart init or add later:
coldstart add auth --providers google,github,appleEach provider needs environment variables, validated at startup:
| Provider | Variables |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| Apple | APPLE_CLIENT_ID, APPLE_CLIENT_SECRET |
OAuth buttons are automatically added to sign-in and sign-up pages on both web and mobile.
Protected routes
Use requireAuth middleware to protect API routes. It returns 401 if no valid session exists:
import { OpenAPIHono } from "@hono/zod-openapi";
import { requireAuth } from "../middleware/session";
import type { AppEnv } from "../types";
export const myRoute = new OpenAPIHono<AppEnv>();
// Protect all routes in this group
myRoute.use(requireAuth);
myRoute.get("/me", (c) => {
const user = c.get("user")!; // guaranteed non-null after requireAuth
return c.json({ id: user.id, email: user.email, name: user.name });
});You can also protect specific route groups in app.ts:
// Public routes
app.route("/", healthRoute);
// Protected routes — require authentication
app.use("/api/v1/*", requireAuth);
app.route("/api/v1", protectedRoutes);Use authClient.useSession() to check auth status in client components:
"use client";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export const AuthGuard = ({ children }: { children: React.ReactNode }) => {
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && !session) {
router.replace("/sign-in");
}
}, [session, isPending, router]);
if (isPending) return null;
if (!session) return null;
return <>{children}</>;
};For layout-level protection, wrap your (app) layout:
import { AuthGuard } from "@/components/auth-guard";
const AppLayout = ({ children }: { children: React.ReactNode }) => (
<AuthGuard>{children}</AuthGuard>
);
export default AppLayout;Password reset flow
The generated project includes a complete password reset flow across all layers:
User requests reset
On the Forgot password page (/forgot-password), the user enters their email. The web client calls:
authClient.forgetPassword({ email, redirectTo: "/reset-password" });This triggers the API to create a verification record with a unique token and expiry.
Email is sent
Better Auth calls the sendResetPassword handler, which uses the Resend integration to send the Reset Password email template from packages/email/src/reset-password.tsx:
export const ResetPasswordEmail = ({ url }: { url: string }) => (
<Html>
<Head />
<Preview>Reset your password</Preview>
<Body>
<Container>
<Heading>Reset your password</Heading>
<Text>Click the link below to reset your password:</Text>
<Link href={url}>Reset Password</Link>
</Container>
</Body>
</Html>
);The link points to your web app's /reset-password?token=... page.
User resets password
The Reset password page extracts the token from the URL and submits the new password:
authClient.resetPassword({ newPassword, token });Better Auth verifies the token hasn't expired, updates the user's password in the account table, and invalidates the verification record.
Preview email templates locally with pnpm -F @my-app/email preview (runs on port 3333). Make sure RESEND_API_KEY is set in apps/api/.env for emails to actually send.