| name | auth |
| description | Setup better-auth authentication with email/password, social providers, role-based access control, account management, and session handling. Use this skill when the user says "setup auth", "add authentication", "setup better-auth", "add login", or "setup user auth". |
| metadata | {"author":"@mattwoodco","version":"2.1.0","created":"2026-01-11","updated":"2026-02-13","dependencies":["env-config","docker","db"]} |
Authentication Setup with better-auth
Sets up a complete authentication system using better-auth with email/password, social OAuth providers, role-based access control, account management, session management, and Drizzle ORM integration.
What Gets Created
Core Files
src/lib/auth.tsx - Server-side auth configuration (JSX for email templates)
src/lib/auth-client.ts - Client-side auth hooks and methods
src/lib/auth-guard.ts - Protected route helpers for API routes
src/app/api/auth/[...all]/route.ts - Auth API route handler
drizzle.config.ts - Drizzle ORM configuration
src/db/index.ts - Database client
src/db/schema/auth.ts - Drizzle schema for auth tables
src/proxy.ts - Route protection (Next.js 16+)
UI Components
src/components/auth/auth-card.tsx - Pre-built sign-in/sign-up card
src/components/nav-bar.tsx - Navigation with user menu dropdown
src/app/(auth)/sign-in/page.tsx - Sign-in page
src/app/(auth)/sign-up/page.tsx - Sign-up page
src/app/(auth)/layout.tsx - Auth pages layout
Account Management
src/app/account/layout.tsx - Account pages layout with sidebar
src/app/account/page.tsx - Profile management (name, email, avatar)
src/app/account/security/page.tsx - Security settings (password, sessions)
src/app/api/account/delete/route.tsx - Account deletion endpoint
Project Structure Note
This skill uses paths with src/ prefix (standard Next.js structure with src directory).
If your project uses --no-src-dir (app and lib in root):
- Replace
src/lib/ with lib/
- Replace
src/app/ with app/
- Replace
src/components/ with components/
- Replace
src/db/ with db/
- Replace
src/emails/ with emails/
- Move
src/proxy.ts to root as proxy.ts
Prerequisites
Before using this skill:
- Docker must be running with PostgreSQL container (dependency:
docker skill)
- env-config skill completed - Requires
env.ts and .env.local with validation
- Recommended: email skill - Provides email templates and sendEmail function
Installation
bun add better-auth @daveyplate/better-auth-ui drizzle-orm postgres drizzle-kit -d
bun add -d @tailwindcss/postcss
Note: @tailwindcss/postcss is required for Tailwind CSS v4.
Environment Variables
Update env.ts
Add these variables to your environment schema:
server: {
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
EMAIL_FROM: z.string().email().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
},
runtimeEnv: {
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
EMAIL_FROM: process.env.EMAIL_FROM,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
},
Add to .env.local
Note: EMAIL_FROM is owned by the email skill (applied first in Layer 1). If the email skill is already applied, EMAIL_FROM is already set — no need to configure it again.
# Auth Core (generate: openssl rand -base64 32)
BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3000
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/store
# Email (for verification/password reset — provided by `email` skill if already applied)
EMAIL_FROM=noreply@example.com
SMTP_HOST=localhost
SMTP_PORT=1025
# Google OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# GitHub OAuth (optional)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
Quick Setup
1. Create Auth Configuration (src/lib/auth.tsx)
IMPORTANT: Use .tsx extension for JSX support in email templates.
Email Template Dependencies: This file requires three email templates:
PasswordResetEmail - For password reset emails
VerificationEmail - For email verification
WelcomeEmail - For new user welcome emails
These should be created using the email skill first, or you can create them manually in src/emails/.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
import * as schema from "@/db/schema/auth";
import { env } from "@/env";
import { sendEmail } from "@/lib/email";
import { PasswordResetEmail } from "@/emails/password-reset";
import { VerificationEmail } from "@/emails/verification";
import { WelcomeEmail } from "@/emails/welcome";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
debugLogs: true,
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Reset your password",
template: <PasswordResetEmail url={url} />,
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your email address",
template: <VerificationEmail verificationUrl={url} />,
});
},
},
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID ?? "",
clientSecret: env.GOOGLE_CLIENT_SECRET ?? "",
enabled: !!env.GOOGLE_CLIENT_ID,
},
github: {
clientId: env.GITHUB_CLIENT_ID ?? "",
clientSecret: env.GITHUB_CLIENT_SECRET ?? "",
enabled: !!env.GITHUB_CLIENT_ID,
},
},
plugins: [
],
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "member",
input: false,
},
},
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
},
},
databaseHooks: {
user: {
create: {
after: async (user) => {
await sendEmail({
to: user.email,
subject: "Welcome to our platform!",
template: <WelcomeEmail userName={user.name} loginUrl={env.BETTER_AUTH_URL} />,
});
},
},
},
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
2. Create Auth Client (src/lib/auth-client.ts)
"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [],
});
export const {
signIn,
signUp,
signOut,
useSession,
getSession,
} = authClient;
3. Create Auth Guard Helpers (src/lib/auth-guard.ts)
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
type Session = typeof auth.$Infer.Session;
type User = Session["user"];
type AuthenticatedHandler<T = unknown> = (
request: NextRequest,
context: { session: Session; user: User }
) => Promise<NextResponse<T> | Response>;
type RoleGuardOptions = {
allowedRoles?: string[];
requireVerifiedEmail?: boolean;
};
export function withAuth<T = unknown>(
handler: AuthenticatedHandler<T>,
options: RoleGuardOptions = {}
): (request: NextRequest) => Promise<NextResponse | Response> {
return async (request: NextRequest) => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{ error: "Unauthorized", message: "Authentication required" },
{ status: 401 }
);
}
if (options.requireVerifiedEmail && !session.user.emailVerified) {
return NextResponse.json(
{ error: "Email not verified" },
{ status: 403 }
);
}
if (options.allowedRoles?.length) {
const userRole = (session.user as { role?: string }).role ?? "member";
if (!options.allowedRoles.includes(userRole)) {
return NextResponse.json(
{ error: "Forbidden", message: "Insufficient permissions" },
{ status: 403 }
);
}
}
return handler(request, { session, user: session.user });
};
}
export function withAdmin<T = unknown>(
handler: AuthenticatedHandler<T>
): (request: NextRequest) => Promise<NextResponse | Response> {
return withAuth(handler, { allowedRoles: ["admin"] });
}
export async function getServerSession(): Promise<Session | null> {
return auth.api.getSession({
headers: await headers(),
});
}
export async function requireAuth(): Promise<{ session: Session; user: User }> {
const session = await getServerSession();
if (!session) {
const { redirect } = await import("next/navigation");
redirect("/sign-in");
throw new Error("Unreachable");
}
return { session, user: session.user };
}
4. Create API Route (src/app/api/auth/[...all]/route.ts)
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
import type { NextRequest } from "next/server";
const handler = toNextJsHandler(auth);
export async function GET(request: NextRequest) {
try {
return await handler.GET(request);
} catch (error) {
console.error("[AUTH GET ERROR]", error);
throw error;
}
}
export async function POST(request: NextRequest) {
try {
return await handler.POST(request);
} catch (error) {
console.error("[AUTH POST ERROR]", error);
throw error;
}
}
5. Setup Drizzle Configuration (drizzle.config.ts)
Create at project root:
import { defineConfig } from "drizzle-kit";
import { config } from "dotenv";
config({ path: ".env.local" });
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema/*.ts",
out: "./src/db/migrations",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
});
6. Create Database Client (src/db/index.ts)
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema/auth";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set");
export const client = postgres(connectionString, { prepare: false });
export const db = drizzle(client, { schema });
7. Create Auth Schema (src/db/schema/auth.ts)
import {
pgTable,
text,
timestamp,
boolean,
} from "drizzle-orm/pg-core";
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"),
role: text("role").notNull().default("member"),
normalizedEmail: text("normalized_email").unique(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
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").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
expiresAt: timestamp("expires_at"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
8. Create Route Protection (src/proxy.ts)
For Next.js 16+, use proxy.ts instead of middleware.ts:
IMPORTANT: The proxy must short-circuit for non-protected routes to avoid calling auth.api.getSession() on every request. Without this, static pages (/, /~offline, etc.) will hang if the database is not running. Also use dynamic import() for the auth module to avoid eager initialization.
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
const protectedRoutes = ["/dashboard", "/settings", "/admin", "/chat", "/account"];
const authRoutes = ["/sign-in", "/sign-up", "/forgot-password"];
const adminRoutes = ["/admin"];
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route));
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
if (!isProtected && !isAuthRoute) {
return NextResponse.next();
}
const { auth } = await import("@/lib/auth");
const session = await auth.api.getSession({
headers: await headers(),
});
if (isProtected && !session) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
if (isAuthRoute && session) {
return NextResponse.redirect(new URL("/", request.url));
}
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
if (isAdminRoute && (session?.user as { role?: string })?.role !== "admin") {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|public).*)"],
};
9. Create Auth Card Component (src/components/auth/auth-card.tsx)
"use client";
import {
AuthUIProvider,
SignInForm,
SignUpForm,
} from "@daveyplate/better-auth-ui";
import { authClient } from "@/lib/auth-client";
interface AuthCardProps {
mode: "sign-in" | "sign-up";
}
export function AuthCard({ mode }: AuthCardProps) {
return (
<AuthUIProvider authClient={authClient}>
{mode === "sign-in" ? (
<SignInForm localization={{}} />
) : (
<SignUpForm localization={{}} />
)}
</AuthUIProvider>
);
}
10. Create Auth Pages Layout (src/app/(auth)/layout.tsx)
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 p-4">
{children}
</div>
);
}
10b. Create Auth Loading State (src/app/(auth)/loading.tsx)
Note: Do NOT use @phosphor-icons/react here — loading.tsx is a server component, and Phosphor icons use createContext which is client-only. Use a CSS spinner instead.
export default function AuthLoading() {
return (
<div className="flex items-center justify-center p-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-zinc-200 border-t-zinc-600" />
</div>
);
}
11. Create Sign-In Page (src/app/(auth)/sign-in/page.tsx)
import { AuthCard } from "@/components/auth/auth-card";
export default function SignInPage() {
return <AuthCard mode="sign-in" />;
}
12. Create Sign-Up Page (src/app/(auth)/sign-up/page.tsx)
import { AuthCard } from "@/components/auth/auth-card";
export default function SignUpPage() {
return <AuthCard mode="sign-up" />;
}
13. Create NavBar with User Menu (src/components/nav-bar.tsx)
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useSession, signOut } from "@/lib/auth-client";
export function NavBar() {
const router = useRouter();
const { data: session, isPending } = useSession();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const handleSignOut = async () => {
await signOut();
router.push("/");
router.refresh();
};
return (
<nav className="bg-white border-b shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link href="/" className="text-xl font-bold">Logo</Link>
</div>
<div className="flex items-center gap-4">
{isPending ? (
<div className="w-8 h-8 rounded-full bg-zinc-100 animate-pulse" />
) : session ? (
<div className="relative">
<button
type="button"
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-zinc-50"
>
<div className="w-8 h-8 rounded-full bg-zinc-200 flex items-center justify-center">
{session.user.name?.charAt(0).toUpperCase()}
</div>
<span className="hidden lg:inline">{session.user.name}</span>
</button>
{userMenuOpen && (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop overlay for closing menu */}
<div
className="fixed inset-0 z-10"
onClick={() => setUserMenuOpen(false)}
onKeyDown={() => {}}
role="presentation"
/>
<div className="absolute right-0 mt-2 w-56 rounded-lg bg-white shadow-lg border z-20">
<div className="px-4 py-3 border-b">
<p className="font-medium">{session.user.name}</p>
<p className="text-xs text-zinc-500">{session.user.email}</p>
</div>
<div className="py-1">
<Link href="/account" className="block px-4 py-2 hover:bg-zinc-50">
Account Settings
</Link>
<Link href="/account/security" className="block px-4 py-2 hover:bg-zinc-50">
Security
</Link>
</div>
<div className="border-t py-1">
<button
type="button"
onClick={handleSignOut}
className="block w-full text-left px-4 py-2 text-red-600 hover:bg-red-50"
>
Sign Out
</button>
</div>
</div>
</>
)}
</div>
) : (
<Link
href="/sign-in"
className="px-4 py-2 rounded-lg bg-zinc-900 text-white hover:bg-zinc-800"
>
Sign In
</Link>
)}
</div>
</div>
</div>
</nav>
);
}
14. Create Account Profile Page (src/app/account/page.tsx)
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useSession, signOut, authClient } from "@/lib/auth-client";
import { CircleNotch } from "@phosphor-icons/react";
export default function AccountPage() {
const router = useRouter();
const { data: session, isPending } = useSession();
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
if (isPending) {
return (
<div className="flex items-center justify-center p-8">
<CircleNotch className="h-6 w-6 animate-spin text-zinc-400" />
</div>
);
}
if (!session) return null;
const handleSave = async () => {
setIsSaving(true);
try {
await authClient.updateUser({ name: name.trim() });
setIsEditing(false);
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (confirm("Are you sure? This cannot be undone.")) {
setIsDeleting(true);
try {
const response = await fetch("/api/account/delete", { method: "DELETE" });
if (response.ok) {
await signOut();
router.push("/");
}
} finally {
setIsDeleting(false);
}
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Profile</h1>
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 rounded-full bg-zinc-200 flex items-center justify-center text-2xl">
{session.user.name?.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium">{session.user.name}</p>
<p className="text-sm text-zinc-500">{session.user.email}</p>
</div>
</div>
{isEditing ? (
<div className="flex gap-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button onClick={handleSave} disabled={isSaving} className="px-4 py-2 bg-zinc-900 text-white rounded-lg disabled:opacity-50">
{isSaving ? <CircleNotch className="h-4 w-4 animate-spin" /> : "Save"}
</button>
<button onClick={() => setIsEditing(false)} disabled={isSaving} className="px-4 py-2 border rounded-lg">
Cancel
</button>
</div>
) : (
<button onClick={() => { setName(session.user.name); setIsEditing(true); }}>
Edit Profile
</button>
)}
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<h2 className="text-lg font-semibold text-red-600 mb-4">Danger Zone</h2>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 disabled:opacity-50"
>
{isDeleting ? <CircleNotch className="h-4 w-4 animate-spin" /> : "Delete Account"}
</button>
</div>
</div>
);
}
15. Create Security Page (src/app/account/security/page.tsx)
"use client";
import { useState, useEffect, useId, useCallback } from "react";
import { useSession, authClient } from "@/lib/auth-client";
import { CircleNotch } from "@phosphor-icons/react";
type SessionData = {
id: string;
token: string;
userAgent?: string | null;
ipAddress?: string | null;
};
export default function SecurityPage() {
const { data: session } = useSession();
const sessionListId = useId();
const [sessions, setSessions] = useState<SessionData[]>([]);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const loadSessions = useCallback(async () => {
const result = await authClient.listSessions();
if (result.data) setSessions(result.data);
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert("Passwords do not match");
return;
}
setIsSubmitting(true);
try {
await authClient.changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
});
setIsChangingPassword(false);
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
} finally {
setIsSubmitting(false);
}
};
const handleRevokeSession = async (token: string) => {
await authClient.revokeSession({ token });
loadSessions();
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Security</h1>
{/* Password Section */}
<div className="bg-white rounded-xl border p-6">
<h2 className="text-lg font-semibold mb-4">Password</h2>
{isChangingPassword ? (
<div className="space-y-4 max-w-md">
<input
type="password"
placeholder="Current Password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
className="w-full px-4 py-2 border rounded-lg"
/>
<input
type="password"
placeholder="New Password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
className="w-full px-4 py-2 border rounded-lg"
/>
<input
type="password"
placeholder="Confirm Password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
className="w-full px-4 py-2 border rounded-lg"
/>
<div className="flex gap-2">
<button onClick={handleChangePassword} disabled={isSubmitting} className="px-4 py-2 bg-zinc-900 text-white rounded-lg disabled:opacity-50">
{isSubmitting ? <CircleNotch className="h-4 w-4 animate-spin" /> : "Update"}
</button>
<button onClick={() => setIsChangingPassword(false)} disabled={isSubmitting} className="px-4 py-2 border rounded-lg">
Cancel
</button>
</div>
</div>
) : (
<button onClick={() => setIsChangingPassword(true)} className="px-4 py-2 border rounded-lg">
Change Password
</button>
)}
</div>
{/* Sessions Section */}
<div className="bg-white rounded-xl border p-6">
<h2 className="text-lg font-semibold mb-4">Active Sessions</h2>
<div className="space-y-4">
{sessions.map((s: SessionData) => (
<div key={`${sessionListId}-${s.id}`} className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">{s.userAgent || "Unknown device"}</p>
<p className="text-sm text-zinc-500">{s.ipAddress || "Unknown IP"}</p>
</div>
{session?.session.token !== s.token && (
<button
onClick={() => handleRevokeSession(s.token)}
className="text-red-600 hover:text-red-700"
>
Revoke
</button>
)}
</div>
))}
</div>
</div>
</div>
);
}
16. Create Account Deletion API (src/app/api/account/delete/route.tsx)
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/db";
import { user, session, account } from "@/db/schema/auth";
import { eq } from "drizzle-orm";
export const DELETE = withAuth(async (request, { user: currentUser }) => {
try {
const userId = currentUser.id;
await db.delete(session).where(eq(session.userId, userId));
await db.delete(account).where(eq(account.userId, userId));
await db.delete(user).where(eq(user.id, userId));
return NextResponse.json({ success: true });
} catch (error) {
console.error("[ACCOUNT DELETE ERROR]", error);
return NextResponse.json({ error: "Failed to delete account" }, { status: 500 });
}
});
OAuth Provider Setup
Google OAuth
- Go to Google Cloud Console
- Create OAuth 2.0 credentials
- Add redirect URI:
http://localhost:3000/api/auth/callback/google
- Copy Client ID and Secret to
.env.local
GitHub OAuth
- Go to GitHub Developer Settings
- Create OAuth App
- Set callback URL:
http://localhost:3000/api/auth/callback/github
- Copy Client ID and Secret to
.env.local
Using Protected Routes
In API Routes
import { withAuth, withAdmin, withRoles } from "@/lib/auth-guard";
import { NextResponse } from "next/server";
export const GET = withAuth(async (request, { user }) => {
return NextResponse.json({ userId: user.id });
});
export const DELETE = withAdmin(async (request, { user }) => {
return NextResponse.json({ deleted: true });
});
export const POST = withRoles(["admin", "moderator"], async (request, { user }) => {
return NextResponse.json({ success: true });
});
In Server Components
import { requireAuth, getServerSession } from "@/lib/auth-guard";
import { redirect } from "next/navigation";
export default async function ProtectedPage() {
const { user } = await requireAuth();
return <div>Hello {user.name}</div>;
}
export default async function OptionalAuthPage() {
const session = await getServerSession();
if (!session) redirect("/sign-in");
return <div>Hello {session.user.name}</div>;
}
Verification and Migration
Before pushing to the database, verify TypeScript compilation:
bunx tsc --noEmit
bunx drizzle-kit push
bun run dev
Testing Without OAuth
For initial testing, focus on email/password authentication:
- Leave OAuth provider environment variables empty in
.env.local
- OAuth providers will be automatically disabled if credentials are not provided
- Social login buttons won't appear in the better-auth UI
- Test with sign-up at
http://localhost:3000/sign-up
- Once working, add OAuth credentials to enable social login
Advanced: Plugins (Optional)
Plugins require additional database tables. Only enable after setup.
emailHarmony Plugin
Normalizes emails (handles gmail dots, plus addressing).
- Schema already includes
normalizedEmail column
- Enable in auth.tsx:
import { emailHarmony } from "better-auth-harmony";
plugins: [emailHarmony()]
Organization Plugin (Multi-tenant)
- Generate tables:
bunx @better-auth/cli generate
- Enable in auth.tsx:
import { organization } from "better-auth/plugins";
plugins: [organization({ allowUserToCreateOrganization: true })]
- Enable in auth-client.ts:
import { organizationClient } from "better-auth/client/plugins";
plugins: [organizationClient()]
Dev Workflow
For quick development sign-in (seed users + one-click sign-in buttons), see the auth-dev skill. It creates a /dev page and seed API endpoint so you can instantly authenticate as any role during development.
E2E Testing with Playwright
For testing protected pages and authentication flows with Playwright, see the e2e skill which includes:
- API-based authentication - Fast auth setup via
/api/auth/sign-in/email
- Multi-role storage states - member, admin, subscribed user contexts
- Auth fixtures -
authenticatedPage, adminPage, pageWithRole() helpers
- Test user seeding - Uses the same
auth-seed.ts users
Quick Setup
- Seed test users (run once):
bun run seed/auth-seed.ts
- Create auth storage states:
bunx playwright test --project=auth-setup
- Run authenticated tests:
bunx playwright test --project=chromium-authenticated
Testing Protected Routes
import { test, expect, getSession } from '../fixtures/auth';
test('authenticated user can access dashboard', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
const session = await getSession(authenticatedPage);
expect(session?.user.email).toBeTruthy();
});
test('admin can access admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin');
await expect(adminPage).toHaveURL(/admin/);
});
Test User Credentials
Default seeded users (from auth-seed.ts):
Seeding Test Users (seed/auth-seed.ts)
CRITICAL: better-auth's password hashing has a subtle implementation detail.
It passes the hex-encoded salt STRING to scrypt, not the raw bytes.
Getting this wrong will cause INVALID_EMAIL_OR_PASSWORD errors during login.
import { scrypt } from "@noble/hashes/scrypt.js";
import { randomBytes } from "node:crypto";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { user, account } from "../src/db/schema/auth";
import { eq } from "drizzle-orm";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("DATABASE_URL required");
process.exit(1);
}
const client = postgres(DATABASE_URL);
const db = drizzle(client);
const testUsers = [
{ email: "admin@example.com", password: "Admin123!", name: "Admin User", role: "admin" },
{ email: "member@example.com", password: "Member123!", name: "Member User", role: "member" },
{ email: "subscribed@example.com", password: "Subscribed123!", name: "Subscribed User", role: "subscribed" },
] as const;
function hashPassword(password: string): string {
const saltBytes = randomBytes(16);
const saltHex = Buffer.from(saltBytes).toString("hex");
const normalizedPassword = password.normalize("NFKC");
const key = scrypt(normalizedPassword, saltHex, {
N: 16384,
r: 16,
p: 1,
dkLen: 64,
maxmem: 128 * 16384 * 16 * 2,
});
const keyHex = Buffer.from(key).toString("hex");
return `${saltHex}:${keyHex}`;
}
function generateId(): string {
return randomBytes(16).toString("hex");
}
async function seed() {
console.log("Seeding test users...\n");
for (const testUser of testUsers) {
const userId = generateId();
const accountId = generateId();
const hashedPassword = hashPassword(testUser.password);
const existingUser = await db
.select()
.from(user)
.where(eq(user.email, testUser.email))
.limit(1);
if (existingUser.length > 0) {
await db
.update(account)
.set({ password: hashedPassword, updatedAt: new Date() })
.where(eq(account.userId, existingUser[0].id));
console.log(` Updated: ${testUser.email}`);
} else {
await db.insert(user).values({
id: userId,
name: testUser.name,
email: testUser.email,
emailVerified: true,
role: testUser.role,
normalizedEmail: testUser.email.toLowerCase(),
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(account).values({
id: accountId,
accountId: userId,
providerId: "credential",
userId: userId,
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
});
console.log(` Created: ${testUser.email}`);
}
}
console.log("\n✅ Test users seeded!");
}
seed()
.catch(console.error)
.finally(() => client.end());
Add to package.json:
{
"scripts": {
"seed:auth": "bun run seed/auth-seed.ts"
}
}
Environment Variables for Testing
TEST_USER_EMAIL=member@example.com
TEST_USER_PASSWORD=Member123!
TEST_ADMIN_EMAIL=admin@example.com
TEST_ADMIN_PASSWORD=Admin123!
Troubleshooting
403 MISSING_OR_NULL_ORIGIN (Playwright API auth)
Symptoms: API-based auth in Playwright fails with 403 FORBIDDEN - MISSING_OR_NULL_ORIGIN
Cause: better-auth validates the Origin header for CORS/security.
Fix: Always include Origin header in fetch requests to better-auth endpoints:
const response = await fetch(`${baseURL}/api/auth/sign-in/email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': baseURL,
'Referer': `${baseURL}/sign-in`,
},
body: JSON.stringify({ email, password }),
});
401 INVALID_EMAIL_OR_PASSWORD (seeded test users)
Symptoms: Auth fails with INVALID_EMAIL_OR_PASSWORD even though users exist in DB.
Cause: Password hashing doesn't match better-auth's algorithm.
Root cause: better-auth passes the hex-encoded salt string to scrypt, not raw bytes.
Fix: Update your seed script:
const key = scrypt(password, saltBytes, { N: 16384, r: 16, p: 1, dkLen: 64 });
const normalizedPassword = password.normalize("NFKC");
const key = scrypt(normalizedPassword, saltHex, { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 });
Sign-up fails silently (422 or 500 errors)
Symptoms: Form submits but nothing happens. Network tab shows 422 or 500.
Common causes:
- Plugins enabled without required tables: Disable all plugins and try again
- Missing database columns: Run
bunx drizzle-kit push
- Database connection issues: Check
DATABASE_URL
Debug:
psql $DATABASE_URL -c "\d user"
"INVALID_EMAIL" error
Cause: emailHarmony() plugin enabled but normalized_email column missing.
Fix: Either add the column or disable the plugin.
"FAILED_TO_CREATE_USER" error
Cause: Plugin requires database tables that don't exist.
Fix: Disable plugins or run bunx @better-auth/cli generate.
Role not available on user type
The role field is added via additionalFields, so TypeScript doesn't include it in the base type. Use type assertion:
const userRole = (session.user as { role?: string }).role ?? "member";