coldstart

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:

apps/api/src/middleware/session.ts
// 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 }:

apps/api/src/types.ts
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:

apps/web/src/lib/auth-client.ts
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.

apps/mobile/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:

TablePurposeKey columns
userUser profilesid, name, email, emailVerified, image
sessionActive sessionsid, token, expiresAt, userId, ipAddress, userAgent
accountOAuth + credential linksid, accountId, providerId, userId, accessToken, password
verificationEmail verification & password reset tokensid, identifier, value, expiresAt
packages/db/src/auth-schema.ts (excerpt)
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:

apps/api/src/lib/auth.ts (session config)
session: {
  expiresIn: 60 * 60 * 24 * 7,  // 7 days
  updateAge: 60 * 60 * 24,       // refresh token once per day
},

How sessions work across platforms:

  • API — the sessionMiddleware reads the session cookie (or Authorization: Bearer header) on every request and populates c.get("user") and c.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 Authorization header.

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:

Terminal
coldstart add auth --providers google,github,apple

Each provider needs environment variables, validated at startup:

ProviderVariables
GoogleGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
GitHubGITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
AppleAPPLE_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:

apps/api/src/routes/my-route.ts
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:

apps/api/src/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:

apps/web/src/components/auth-guard.tsx
"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:

apps/web/src/app/(app)/layout.tsx
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:

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.

On this page