| name | better-auth |
| description | Better Auth integration expertise for TypeScript authentication. Triggers when implementing auth flows, configuring OAuth providers, setting up sessions, email verification, password reset, or any file importing from better-auth. Use for auth configuration, plugin setup, database adapters, session management, hooks, and security best practices. |
Better Auth
Better Auth is a TypeScript-first, framework-agnostic authentication framework. Comprehensive, extensible, battle-tested.
Always consult better-auth.com/docs for latest API and examples.
Decision Tree
Is this a new/empty project?
├─ YES → New project setup
│ 1. Identify framework (React Router, Next.js, etc.)
│ 2. Choose database adapter
│ 3. Install better-auth + scoped packages
│ 4. Create auth.server.ts + auth.client.ts
│ 5. Set up route handler
│ 6. Run CLI migrate/generate
│ 7. Add features via plugins
│
└─ NO → Does project have existing auth?
├─ YES → Migration
│ • Audit current auth for gaps
│ • Plan incremental migration
│ • See migration guides: Auth0, Clerk, Auth.js, Supabase
│
└─ NO → Add auth to existing project
1. Analyze project structure
2. Install better-auth
3. Create auth config files
4. Add route handler
5. Run schema migrations
6. Integrate into existing pages
Quick Reference
Installation
Use the project's package manager (detect from lock file).
<pkg> add better-auth
<pkg> add @better-auth/passkey
<pkg> add @better-auth/sso
<pkg> add @better-auth/stripe
<pkg> add @better-auth/expo
npx @better-auth/cli@latest migrate
npx @better-auth/cli@latest generate
Re-run CLI after adding/changing plugins.
Environment Variables
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
Only set baseURL/secret in config if env vars are NOT set.
Core Config Options
| Option | Notes |
|---|
database | Required. pg.Pool, mysql2, sqlite, or ORM adapter |
emailAndPassword | { enabled: true } to activate |
socialProviders | { google: { clientId, clientSecret }, ... } |
plugins | Array of plugins |
session | Expiry, cookie cache settings |
secondaryStorage | Redis/KV for sessions & rate limits |
trustedOrigins | CSRF whitelist |
basePath | Default /api/auth. Set / for root |
Server Config
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: pool,
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [
],
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
Client Setup
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_APP_URL,
plugins: [],
});
Route Handler by Framework
| Framework | File | Handler |
|---|
| React Router v7 | app/routes/api.auth.$.ts | auth.handler(request) |
| Next.js App Router | app/api/auth/[...all]/route.ts | toNextJsHandler(auth) |
| Next.js Pages | pages/api/auth/[...all].ts | toNextJsHandler(auth) |
| SvelteKit | src/hooks.server.ts | svelteKitHandler(auth) |
| SolidStart | Route file | solidStartHandler(auth) |
| Hono | Route file | auth.handler(c.req.raw) |
| Express | Any file | app.all("/api/auth/*", toNodeHandler(auth)) |
React Router v7 Integration
import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/api.auth.$";
export async function loader({ request }: Route.LoaderArgs) {
return auth.handler(request);
}
export async function action({ request }: Route.ActionArgs) {
return auth.handler(request);
}
import { redirect } from "react-router";
import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/_protected";
export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw redirect("/login");
return { user: session.user };
}
export default function ProtectedLayout({ loaderData }: Route.ComponentProps) {
return <Outlet context={{ user: loaderData.user }} />;
}
import { authClient } from "~/lib/auth.client";
export default function Login() {
const handleGoogleLogin = () => {
authClient.signIn.social({ provider: "google" });
};
const handleEmailLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await authClient.signIn.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
};
return (
<div>
<button onClick={handleGoogleLogin}>Sign in with Google</button>
<form onSubmit={handleEmailLogin}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign In</button>
</form>
</div>
);
}
Database Adapters
| Database | Setup |
|---|
| PostgreSQL | new Pool({ connectionString }) directly |
| MySQL | mysql2 pool directly |
| SQLite | better-sqlite3 or bun:sqlite instance |
| Drizzle | drizzleAdapter(db, { provider: "pg" }) |
| Prisma | prismaAdapter(prisma, { provider: "postgresql" }) |
| MongoDB | mongodbAdapter(client.db("mydb")) |
Critical: Use ORM model names, NOT table names. If Prisma model is User mapping to users table, use modelName: "user".
Session Management
Storage priority:
- If
secondaryStorage defined → sessions go there (not DB)
- Set
session.storeSessionInDatabase: true to also persist to DB
- No database +
cookieCache → fully stateless mode
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
strategy: "compact",
maxAge: 60 * 5,
version: 1,
},
storeSessionInDatabase: true,
}
Cookie cache strategies:
compact (default) - Base64url + HMAC. Smallest size.
jwt - Standard JWT. Readable but signed.
jwe - Encrypted. Maximum security.
Email Configuration
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Reset your password",
html: `<a href="${url}">Reset password</a>`,
});
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your email",
html: `<a href="${url}">Verify email</a>`,
});
},
},
Hooks
hooks: {
before: [
{
matcher: (ctx) => ctx.path.startsWith("/api/auth"),
handler: createAuthMiddleware(async (ctx) => {
console.log("Auth request:", ctx.path);
return { context: ctx.context };
}),
},
],
after: [
{
matcher: (ctx) => ctx.path === "/api/auth/sign-up",
handler: createAuthMiddleware(async (ctx) => {
if (ctx.context.returned) {
await trackSignUp(ctx.context.returned.user);
}
}),
},
],
},
databaseHooks: {
user: {
create: {
before: async (user) => {
return { data: { ...user, role: "member" } };
},
after: async (user) => {
await createDefaultWorkspace(user.id);
},
},
},
},
Hook context (ctx.context): session, secret, authCookies, password.hash()/verify(), adapter, internalAdapter, generateId(), tables, baseURL.
Plugins
Import from dedicated paths for tree-shaking:
import { twoFactor } from "better-auth/plugins/two-factor";
| Plugin | Server Import | Client Import | Purpose |
|---|
twoFactor | better-auth/plugins/two-factor | twoFactorClient | 2FA with TOTP/OTP |
organization | better-auth/plugins/organization | organizationClient | Teams/orgs |
admin | better-auth/plugins/admin | adminClient | User management |
passkey | @better-auth/passkey | passkeyClient | WebAuthn |
magicLink | better-auth/plugins/magic-link | magicLinkClient | Passwordless |
bearer | better-auth/plugins/bearer | - | API token auth |
jwt | better-auth/plugins/jwt | - | JWT tokens |
sso | @better-auth/sso | - | Enterprise SSO |
openAPI | better-auth/plugins/open-api | - | API docs |
Plugin pattern: Server plugin + client plugin + run migrations.
Security Options
advanced: {
useSecureCookies: true,
crossSubDomainCookies: {
enabled: true,
domain: ".example.com",
},
ipAddress: {
ipAddressHeaders: ["x-forwarded-for", "x-real-ip"],
},
database: {
generateId: "uuid",
},
},
rateLimit: {
enabled: true,
window: 60,
max: 10,
storage: "secondary-storage",
},
trustedOrigins: ["https://app.example.com"],
Security Checklist
Client Methods Reference
authClient.signUp.email({ email, password, name });
authClient.signIn.email({ email, password });
authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" });
const session = authClient.useSession();
const session = await authClient.getSession();
await authClient.signOut();
await authClient.revokeSession({ sessionId });
await authClient.revokeSessions();
Troubleshooting
| Issue | Cause | Fix |
|---|
| "Secret not set" | Missing env var | Add BETTER_AUTH_SECRET to environment |
| "Invalid Origin" | CSRF protection | Add domain to trustedOrigins array |
| Sessions not persisting | secondaryStorage defined | Sessions go to Redis by default. Set storeSessionInDatabase: true |
| Cookies not setting | Domain mismatch | Check baseURL matches domain; enable secure cookies in prod |
| Custom fields missing | Cookie cache limit | Custom session fields always re-fetch from DB |
| Plugin schema missing | CLI not re-run | Run npx @better-auth/cli migrate after adding plugins |
| Model not found | Table vs model name | Use ORM model name, not DB table name |
| Email verification failing | No handler | Must define sendVerificationEmail function |
| Logout not working | Stateless mode | No DB = session in cookie only, clears on cache expiry |
| OAuth callback errors | Redirect URI | Verify redirect URIs in provider dashboard |
| Type errors after plugin | Schema outdated | Re-run CLI generate/migrate |
| Change email confusing | Two-step flow | Sends to current email first, then new email |
Resources