| name | better-auth |
| description | Authentication and authorization with better-auth in Spiceflow and TypeScript apps. Covers server config with Drizzle adapter (Postgres and SQLite), Spiceflow middleware for forwarding auth requests, client setup, session middleware, social and email/password auth, server-side session checks, and React client hooks (useSession, signIn, signOut, signUp). Also covers device authorization (CLI device flow), bearer token auth, and server actions with auth. ALWAYS load this skill when a project uses better-auth.
|
better-auth
better-auth is the most comprehensive authentication framework for TypeScript. It provides email/password, social OAuth, session management, 2FA, and more out of the box. It works with any backend that uses standard Request/Response objects.
Full docs: https://better-auth.com/llms.txt
When you need docs for a better-auth feature not covered in this skill (specific plugin API, config options, edge cases), use WebFetch to fetch https://better-auth.com/llms.txt. It contains the full better-auth documentation in a single file optimized for LLMs.
URL construction
Always use new URL(path, base) instead of string concatenation or template literals for building URLs:
const url = new URL('/api/auth', process.env.BETTER_AUTH_URL)
const url = `${process.env.BETTER_AUTH_URL}/api/auth`
const url = process.env.BETTER_AUTH_URL + '/api/auth'
new URL handles trailing slashes, normalizes paths, and avoids double-slash bugs.
Installation
With drizzle-orm v0 (stable):
pnpm add better-auth @better-auth/drizzle-adapter
With drizzle-orm v1 (beta) — requires the PR #9489 build and the /relations-v2 subpath:
pnpm add better-auth@"https://pkg.pr.new/better-auth@9489" @better-auth/drizzle-adapter@"https://pkg.pr.new/@better-auth/drizzle-adapter@9489"
pnpm add drizzle-orm@beta
Important: With drizzle-orm v1, you must import from the /relations-v2 subpath:
import { drizzleAdapter } from '@better-auth/drizzle-adapter/relations-v2'
The default export (@better-auth/drizzle-adapter) only works with drizzle-orm v0.
Server config
Create src/lib/auth.ts (or lib/auth.ts). Export the auth instance as auth.
Drizzle adapter (Postgres)
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
import { db } from 'db'
import * as schema from 'db/schema'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema,
}),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!,
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
})
Drizzle adapter (SQLite)
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
import { db } from 'db'
import * as schema from 'db/schema'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'sqlite',
schema,
}),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!,
emailAndPassword: { enabled: true },
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
})
Environment variables
BETTER_AUTH_URL is always a secret, never a plain env var or hardcoded value. It differs per environment: dev uses http://localhost:3000, preview uses the preview deploy URL, production uses the real domain. Treat it the same as BETTER_AUTH_SECRET.
BETTER_AUTH_URL must match the Origin header the browser sends. BetterAuth validates the Origin header on every /api/auth/* request against the configured baseURL. If they don't match, you get 403 {"message":"Invalid origin","code":"INVALID_ORIGIN"}. This commonly happens when secrets management tools (Sigillo, Doppler) inject the production URL during local dev. Set it correctly in sigillo or doppler with BETTER_AUTH_URL secret. Or use a wrangler.json vars variable. Also make sure sigillo is configured to the dev env locally, check with sigillo me
For Cloudflare Workers, put both in secrets.required in wrangler.jsonc:
{
"secrets": {
"required": [
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET"
]
}
}
For Doppler/Sigillo, set per-environment values:
| Variable | development | preview | production |
|---|
BETTER_AUTH_URL | http://localhost:3000 | https://preview.example.com | https://example.com |
BETTER_AUTH_SECRET | (random 32+ chars) | (random 32+ chars) | (random 32+ chars) |
BETTER_AUTH_SECRET= # min 32 chars, generate with: openssl rand -base64 32
BETTER_AUTH_URL= # MUST be set per env — never hardcode
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Schema generation
better-auth manages its own tables (user, session, account, verification). Generate the Drizzle schema for them:
pnpm dlx auth@latest generate
This outputs a Drizzle schema file. Add the generated tables to your src/schema.ts and run drizzle-kit generate + drizzle-kit migrate as usual.
When you add plugins that require new tables (2FA, organization, etc.), re-run pnpm dlx auth@latest generate to update the schema.
Spiceflow integration
Auth route middleware
In Spiceflow, mount better-auth using a .use() middleware that forwards requests with the /api/auth prefix to auth.handler(). If auth returns a 404 (no matching auth endpoint), fall through to your own routes instead of returning the 404:
import { Spiceflow } from 'spiceflow'
import { auth } from './lib/auth'
export const app = new Spiceflow()
.use(async ({ request }, next) => {
if (request.parsedUrl.pathname.startsWith('/api/auth')) {
const response = await auth.handler(request)
if (response.ok || response.status !== 404) return response
}
return next()
})
Use res.ok || res.status !== 404 instead of just res.status === 404 so auth error responses (401, 403, 400) are returned directly instead of falling through to your app routes:
.use(async ({ request }, next) => {
if (request.parsedUrl.pathname.startsWith('/api/auth')) {
const response = await auth.handler(request)
if (response.ok || response.status !== 404) return response
}
return next()
})
This handles ALL better-auth endpoints (sign-in, sign-up, OAuth callback, session, etc.). The middleware short-circuits for auth paths and returns the auth response directly. Non-auth paths and unmatched auth paths fall through to next().
Session state + loader
Use .state() to resolve the session once in middleware, then expose it to all pages and client components via a /* loader. This is the recommended pattern — it's fully type-safe and avoids prop drilling:
import { Spiceflow, redirect } from 'spiceflow'
import { auth } from './lib/auth'
type AuthSession = typeof auth.$Infer.Session | null
export const app = new Spiceflow()
.use(async ({ request }, next) => {
if (request.parsedUrl.pathname.startsWith('/api/auth')) {
const response = await auth.handler(request)
if (response.ok || response.status !== 404) return response
}
return next()
})
.state('session', null as AuthSession)
.use(async ({ request, state }) => {
state.session = await auth.api.getSession({ headers: request.headers })
})
.loader('/*', ({ state }) => {
return { session: state.session }
})
This runs on every request including landing pages. When no session cookie is present, getSession returns null immediately (no DB query). When a session exists and cookie caching is enabled (which it should always be), getSession reads the signed cookie and skips the database entirely. The DB is only hit once every maxAge interval (default 5 minutes) to refresh the cache.
Now every page, layout, and client component can access the session type-safely:
In server components (pages/layouts) — via loaderData:
.layout('/*', async ({ loaderData, children }) => {
return (
<html>
<body>
{loaderData.session && <nav>{loaderData.session.user.name}</nav>}
{children}
</body>
</html>
)
})
.page('/dashboard', async ({ loaderData }) => {
if (!loaderData.session) return redirect('/login')
return <Dashboard user={loaderData.session.user} />
})
In client components — via useLoaderData hook from spiceflow/react:
'use client'
import { useLoaderData } from 'spiceflow/react'
export function UserMenu() {
const { session } = useLoaderData('/*')
if (!session) return <a href="/login">Sign in</a>
return (
<div>
<span>{session.user.name}</span>
<button onClick={async () => {
await authClient.signOut()
window.location.href = '/login'
}}>Sign out</button>
</div>
)
}
The /* loader matches all pages, so session is always available in useLoaderData. When multiple loaders match (e.g. /* and /dashboard), their return values are merged into a single flat object — more specific loaders override less specific ones on key conflicts.
Protecting API routes
For API routes (not pages), use state.session directly since loaders only run for pages:
.route({
method: 'POST',
path: '/api/posts',
request: z.object({
title: z.string(),
content: z.string(),
}),
async handler({ request, state }) {
if (!state.session) {
return new Response('Unauthorized', { status: 401 })
}
const body = await request.json()
return { id: '1', authorId: state.session.user.id }
},
})
Server actions with auth
Spiceflow server actions ('use server' functions) run in a different request context than the page render. You cannot access the page's request or state directly. Use getActionRequest() from spiceflow to get the action's request, then call requireSession() on it:
import { getActionRequest, parseFormData } from 'spiceflow'
async function deletePost(formData: FormData) {
'use server'
const request = getActionRequest()
const session = await requireSession(request)
const { postId } = parseFormData(z.object({ postId: z.string() }), formData)
await db.delete(posts).where(eq(posts.id, postId))
throw redirect('/posts')
}
Always call requireSession(getActionRequest()) at the top of every server action that mutates data. The action request carries the user's cookies/auth headers, so getSession works the same as in route handlers.
Full Spiceflow app example
import { Spiceflow, redirect } from 'spiceflow'
import { auth } from './lib/auth'
import { z } from 'zod'
type AuthSession = typeof auth.$Infer.Session | null
export const app = new Spiceflow()
.use(async ({ request }, next) => {
if (request.parsedUrl.pathname.startsWith('/api/auth')) {
const response = await auth.handler(request)
if (response.ok || response.status !== 404) return response
}
return next()
})
.state('session', null as AuthSession)
.use(async ({ request, state }) => {
state.session = await auth.api.getSession({ headers: request.headers })
})
.loader('/*', ({ state }) => {
return { session: state.session }
})
.page('/login', async ({ loaderData }) => {
if (loaderData.session) return redirect('/')
const { LoginButton } = await import('./components/login-button')
return <LoginButton />
})
.page('/dashboard', async ({ loaderData }) => {
if (!loaderData.session) return redirect('/login')
return <div>Hello, {loaderData.session.user.name}</div>
})
.get('/api/me', ({ state }) => {
if (!state.session) return new Response('Unauthorized', { status: 401 })
return state.session.user
})
declare module 'spiceflow/react' {
interface SpiceflowRegister { app: typeof app }
}
Client setup
React client
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_URL,
})
export const { signIn, signUp, signOut, useSession } = authClient
With plugins
import { createAuthClient } from 'better-auth/react'
import { twoFactorClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_URL,
plugins: [
twoFactorClient({
twoFactorPage: '/two-factor',
}),
],
})
export const { signIn, signUp, signOut, useSession } = authClient
Vanilla client (non-React)
import { createAuthClient } from 'better-auth/client'
export const authClient = createAuthClient({
})
Client usage patterns
useSession — reactive session in components
This is discouraged. Prefer passing down session via spiceflow loaders or props instead.
import { useSession } from '@/lib/auth-client'
function UserProfile() {
const { data: session, isPending, error } = useSession()
if (isPending) return <div>Loading...</div>
if (!session) return <div>Not signed in</div>
return <div>Hello, {session.user.name}</div>
}
Sign in with email/password
import { signIn } from '@/lib/auth-client'
await signIn.email(
{
email: 'user@example.com',
password: 'password123',
callbackURL: '/dashboard',
rememberMe: true,
},
{
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => toast.error(ctx.error.message),
},
)
Sign in with social provider (Google)
import { signIn } from '@/lib/auth-client'
await signIn.social({
provider: 'google',
callbackURL: '/dashboard',
})
Sign up
import { signUp } from '@/lib/auth-client'
await signUp.email({
email: 'user@example.com',
password: 'password123',
name: 'John Doe',
image: '',
callbackURL: '/dashboard',
fetchOptions: {
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => toast.error(ctx.error.message),
},
})
Sign out
import { signOut } from '@/lib/auth-client'
await signOut({
fetchOptions: {
onSuccess: () => router.push('/login'),
},
})
Using with Spiceflow typed fetch client
When calling authenticated Spiceflow API routes from the client, use createSpiceflowFetch with credentials: 'include' so cookies are sent:
import { createSpiceflowFetch } from 'spiceflow/client'
const safeFetch = createSpiceflowFetch(new URL('/', process.env.NEXT_PUBLIC_URL!).href)
const me = await safeFetch('/api/me', {
fetch: { credentials: 'include' },
})
if (me instanceof Error) {
console.error(me.message)
return
}
console.log(me.name, me.email)
Server-side session checks
With the /* loader pattern above, session is already available in loaderData for all pages. For standalone server code that needs a session outside of Spiceflow (scripts, cron jobs, etc.):
import { auth } from './lib/auth'
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session) {
}
Session caching
Always enable cookie caching. Without it, every getSession call hits the database. With cookie caching, the session is stored in a signed cookie and getSession just verifies the signature; zero database queries on most requests. This is especially important in Spiceflow apps where the /* loader calls getSession on every single page load.
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 5 * 60,
strategy: 'compact',
},
},
})
Every betterAuth() config in this skill and in new projects must include session.cookieCache.enabled: true. Omitting it means a database round-trip per request, which adds latency and load for no reason.
To bypass the cache for sensitive operations (e.g. before a destructive action):
const session = await auth.api.getSession({
headers: request.headers,
query: { disableCookieCache: true },
})
Plugins
better-auth has a plugin system for adding features. Common plugins:
Two-factor authentication
Server:
import { betterAuth } from 'better-auth'
import { twoFactor } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [twoFactor()],
})
Client:
import { createAuthClient } from 'better-auth/react'
import { twoFactorClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
plugins: [twoFactorClient({ twoFactorPage: '/two-factor' })],
})
After adding plugins, re-run pnpm dlx auth@latest generate to generate updated schema, then run drizzle migrations.
Device authorization (CLI device flow)
Use the deviceAuthorization plugin when your app has a CLI companion that needs to authenticate via a browser. The CLI displays a user code, opens a browser to your verification page, and polls until the user approves.
Server:
import { betterAuth } from 'better-auth'
import { deviceAuthorization, bearer } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [
deviceAuthorization({ verificationUri: '/device', schema: {} }),
bearer(),
],
})
IMPORTANT: pass schema: {} to deviceAuthorization(). In better-auth@1.6.9+, the plugin's Zod options schema has schema: z.custom(() => true) which is non-optional. Without passing it, the plugin throws a ZodError at init time: "expected": "nonoptional", "path": ["schema"]. The schema field is only for user-provided table overrides and the plugin merges it with its built-in schema via mergeSchema(). Passing an empty object is safe and satisfies the validator. No as any cast needed; the published types accept {}.
// Error without schema field:
// ZodError: [{ "code": "invalid_type", "expected": "nonoptional",
// "path": ["schema"], "message": "Invalid input: ..." }]
// at deviceAuthorization (better-auth/dist/plugins/device-authorization/index.mjs)
**Schema:** The plugin requires a `device_code` table. Generate it with `pnpm dlx auth@latest generate`. The table stores device codes, user codes, expiry, and approval status.
```ts
// import * as s from 'drizzle-orm/sqlite-core'
export const deviceCode = s.sqliteTable('device_code', {
id: s.text('id').primaryKey().notNull().$defaultFn(() => ulid()),
deviceCode: s.text('device_code').notNull().unique(),
userCode: s.text('user_code').notNull().unique(),
userId: s.text('user_id').references(() => user.id, { onDelete: 'cascade' }),
expiresAt: epochMs('expires_at').notNull(),
status: s.text('status', {
enum: ['pending', 'approved', 'denied', 'expired'],
}).notNull().default('pending'),
lastPolledAt: epochMs('last_polled_at'),
pollingInterval: s.integer('polling_interval', { mode: 'number' }),
clientId: s.text('client_id'),
scope: s.text('scope'),
})
Verification page (Spiceflow):
The device flow verification page must:
- Check the user code is valid via
auth.api.deviceVerify()
- Require the user to be signed in (redirect to login if not)
- Provide approve/deny actions via server actions
import { getActionRequest, json, parseFormData, Spiceflow, redirect } from 'spiceflow'
import { router } from 'spiceflow/react'
import { z } from 'zod'
const devicePageQuerySchema = z.object({
user_code: z.string().optional(),
status: z.enum(['approved', 'denied']).optional(),
})
const deviceUserCodeSchema = z.object({ userCode: z.string().min(1) })
export const app = new Spiceflow()
.page({
path: '/device',
query: devicePageQuerySchema,
handler: async ({ request, query }) => {
const userCode = query.user_code ?? ''
const status = query.status
if (!userCode) {
return <div>Open this page from the CLI login flow.</div>
}
const auth = getAuth()
const device = await auth.api.deviceVerify({
query: { user_code: userCode },
}).catch(() => null)
if (!device) {
return <div>Invalid or expired device code.</div>
}
const session = await getSession(request)
if (!session) {
throw redirect(router.href('/login', {
callbackURL: `${request.parsedUrl.pathname}${request.parsedUrl.search}`,
}))
}
async function approveDevice(formData: FormData) {
'use server'
const actionRequest = getActionRequest()
await requireSession(actionRequest)
const { userCode: code } = parseFormData(deviceUserCodeSchema, formData)
const actionAuth = getAuth()
await actionAuth.api.deviceApprove({
body: { userCode: code },
headers: actionRequest.headers,
})
throw redirect(router.href('/device', {
user_code: code,
status: 'approved',
}))
}
async function denyDevice(formData: FormData) {
'use server'
const actionRequest = getActionRequest()
await requireSession(actionRequest)
const { userCode: code } = parseFormData(deviceUserCodeSchema, formData)
const actionAuth = getAuth()
await actionAuth.api.deviceDeny({
body: { userCode: code },
headers: actionRequest.headers,
})
throw redirect(router.href('/device', {
user_code: code,
status: 'denied',
}))
}
if (status === 'approved') {
return <div>CLI approved. You can close this page.</div>
}
if (status === 'denied') {
return <div>CLI denied. You can close this page.</div>
}
return (
<div>
<p>A CLI is requesting access. Code: {userCode}</p>
<form action={approveDevice}>
<input type="hidden" name="userCode" value={userCode} />
<button type="submit">Approve</button>
</form>
<form action={denyDevice}>
<input type="hidden" name="userCode" value={userCode} />
<button type="submit">Deny</button>
</form>
</div>
)
},
})
CLI side (polling loop):
import { createAuthClient } from 'better-auth/client'
const client = createAuthClient({ baseURL: 'https://myapp.com' })
const { data } = await client.deviceAuthorization.request()
console.log(`Open: ${data.verificationUri}?user_code=${data.userCode}`)
console.log(`Code: ${data.userCode}`)
open(data.verificationUriComplete)
const result = await client.deviceAuthorization.verifyDevice({
deviceCode: data.deviceCode,
})
Bearer token auth
The bearer plugin lets clients authenticate with Authorization: Bearer <session-token> instead of cookies. Essential for CLI tools, API clients, and mobile apps.
Server:
import { betterAuth } from 'better-auth'
import { bearer } from 'better-auth/plugins'
export const auth = betterAuth({
plugins: [bearer()],
})
No client plugin needed. The CLI or API client just sends the session token as a Bearer header:
const response = await fetch('https://myapp.com/api/me', {
headers: { Authorization: `Bearer ${sessionToken}` },
})
auth.api.getSession({ headers }) automatically checks both cookies and the Authorization header when the bearer plugin is enabled. No code changes needed in your session resolution logic.
Other plugins
- organization — multi-tenant orgs with roles and teams
- passkey — WebAuthn/passkey authentication
- magic-link — passwordless email links
- email-otp — one-time password via email
- username — username-based auth
- admin — admin panel and user management
- bearer — Bearer token auth for APIs
- api-key — API key authentication
See https://better-auth.com/llms.txt for full plugin docs.
Spiceflow page examples
Login page
A standalone login page that redirects to the dashboard if already authenticated. Uses loaderData.session from the /* loader — no need to call getSession again:
.page('/login', async ({ loaderData }) => {
if (loaderData.session) return redirect('/')
const { LoginButton } = await import('./components/login-button')
return (
<div className="flex justify-center items-center min-h-[60vh]">
<div className="text-center max-w-sm">
<h1 className="text-2xl font-bold tracking-tight mb-2">My App</h1>
<p className="text-muted-foreground mb-6">Sign in to continue</p>
<LoginButton callbackURL="/" />
</div>
</div>
)
})
'use client'
import { useState } from 'react'
import { authClient } from '../lib/auth-client'
export function LoginButton({ callbackURL = '/' }: { callbackURL?: string }) {
const [loading, setLoading] = useState(false)
return (
<button
onClick={async () => {
setLoading(true)
await authClient.signIn.social({
provider: 'google',
callbackURL,
})
}}
disabled={loading}
className="h-10 px-6 rounded-lg bg-primary text-primary-foreground font-semibold"
>
{loading ? 'Redirecting...' : 'Sign in with Google'}
</button>
)
}
Root redirect for authenticated users
.get('/', async ({ state }) => {
if (!state.session) return redirect('/login')
return redirect('/dashboard')
})
Protected layout with session
Use a layout to enforce auth for a group of pages. The session is available from loaderData (provided by the /* loader), so the layout just checks it and renders:
.layout('/app/*', async ({ loaderData, children }) => {
if (!loaderData.session) return redirect('/login')
const { user } = loaderData.session
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r p-4">
<div className="text-sm text-muted-foreground">{user.email}</div>
<nav>{/* sidebar links */}</nav>
{/* Use a client component with authClient.signOut() for sign-out */}
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
)
})
Protected page
Pages under a protected layout don't need to re-check auth — the layout already redirected unauthenticated users. Session data is still available via loaderData:
.page('/app/settings', async ({ loaderData }) => {
const { user } = loaderData.session!
return (
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p>Signed in as {user.name} ({user.email})</p>
</div>
)
})
Protected API route
API routes don't use loaders — use state.session directly:
.route({
method: 'POST',
path: '/api/posts',
request: z.object({
title: z.string().min(1),
content: z.string(),
}),
async handler({ request, state }) {
if (!state.session) {
return new Response(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: { 'content-type': 'application/json' },
})
}
const body = await request.json()
const post = await createPost({ ...body, authorId: state.session.user.id })
return { ok: true, id: post.id }
},
})
Sign out button
Do NOT use <a href="/api/auth/sign-out"> — the GET sign-out endpoint does not work reliably. Always use the client method which POSTs to the correct endpoint:
'use client'
import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient()
function SignOutButton() {
return (
<button onClick={async () => {
await authClient.signOut()
window.location.href = '/login'
}}>
Sign out
</button>
)
}
Reading session in any client component
Any client component can read the session via useLoaderData without props — it's type-safe and always available from the /* loader:
'use client'
import { useLoaderData } from 'spiceflow/react'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { session } = useLoaderData('/*')
if (!session) return <a href="/login">Please sign in</a>
return <>{children}</>
}
Drizzle ORM v1 (beta) compatibility
The official @better-auth/drizzle-adapter on npm targets drizzle-orm v0.x and does not work with drizzle-orm@beta (v1.0.0-beta). The adapter relies on v0 APIs (db._.fullSchema, db.query) that changed in v1 — you get errors like "model 'user' was not found in the schema object".
To use drizzle-orm@beta (v1), install both packages from PR #9489 which adds v1 support:
pnpm add better-auth@"https://pkg.pr.new/better-auth@9489" @better-auth/drizzle-adapter@"https://pkg.pr.new/@better-auth/drizzle-adapter@9489"
pnpm add drizzle-orm@beta
This is a pre-release build from https://github.com/better-auth/better-auth/pull/9489 — it works but is not yet merged into better-auth main. Track progress at https://github.com/better-auth/better-auth/issues/6766.
Once the PR is merged, switch back to pnpm add @better-auth/drizzle-adapter@latest.
Usage requires the /relations-v2 subpath import. The default export does not work with drizzle-orm v1:
import { drizzleAdapter } from '@better-auth/drizzle-adapter/relations-v2'
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
}),
})
TS2742 from exported getAuth() in app packages
If a private app exports a BetterAuth instance factory like getAuth() and tsc fails with TS2742, first check whether the app package is emitting declaration files.
The error looks like this:
The inferred type of 'getAuth' cannot be named without a reference to '../node_modules/better-auth/dist/types/auth.d.mts'. This is likely not portable. A type annotation is necessary.
This usually happens when declaration, declarationMap, emitDeclarationOnly, or composite forces TypeScript to emit .d.ts files for an app package. The inferred BetterAuth return type is large and can include transitive pnpm/pkg-pr-new internals, so TypeScript cannot print a portable public type.
For private application packages, prefer disabling declaration emit instead of writing a fake wrapper type:
{
"compilerOptions": {
"noEmit": true
}
}
Then keep getAuth() exported normally:
export function getAuth() {
const db = getDb()
return betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
})
}
Only keep declaration emit for packages that are actually consumed as libraries. If a package is only a Vite/Spiceflow app, Vite emits the runtime build and tsc --noEmit is the right typecheck path.
If the package must emit declarations, add an explicit real exported BetterAuth type annotation instead of hand-writing a partial auth shape. Also try pnpm dedupe better-auth @better-auth/core @better-auth/drizzle-adapter, but do not expect dedupe to fix TS2742 when declaration emit is the root cause.
Server-side API calls and cookies
When calling auth.api.* methods server-side (e.g. auth.api.signInSocial()), the response cookies (state cookies, session cookies) are not automatically sent to the browser. If you extract just the URL and create your own Response.redirect(), all Set-Cookie headers are lost.
This causes state_mismatch errors on OAuth callbacks because BetterAuth stores a signed state cookie for CSRF protection. Without it, the callback validation fails.
Always use returnHeaders: true and manually forward cookies when the result is a browser redirect:
.get('/sign-in', async ({ request }) => {
const auth = getAuth()
const res = await auth.api.signInSocial({
body: { provider: 'google', callbackURL: request.url },
})
return Response.redirect(res.url, 302)
})
.get('/sign-in', async ({ request }) => {
const auth = getAuth()
const { response: result, headers } = await auth.api.signInSocial({
body: { provider: 'google', callbackURL: request.url },
headers: request.headers,
returnHeaders: true,
})
if (!result?.url) {
return new Response('Failed to initiate sign-in', { status: 500 })
}
const redirect = new Response(null, { status: 302, headers: { Location: result.url } })
for (const cookie of headers.getSetCookie()) {
redirect.headers.append('Set-Cookie', cookie)
}
return redirect
})
Do NOT use asResponse: true for signInSocial — it returns a JSON Response with { url, redirect: true } body, not a 302 redirect. The redirect is client-side behavior. On the server you must build the redirect yourself.
When to use each pattern:
returnHeaders: true — when you need Set-Cookie headers from the response (OAuth redirects, sign-in flows that set state cookies). Returns { headers, response } where response is the parsed body and headers is a Headers object with getSetCookie().
asResponse: true — when you want the raw Response object (rarely useful for signInSocial since it returns JSON, not a redirect).
- Default (no flag) — when you just need the data (e.g.
getSession). Returns the parsed body directly. Cookies are not forwarded.
headers parameter — always pass the original request.headers when the API call needs request context (cookies, user agent, IP). Without it, BetterAuth can't read existing cookies or set new ones with the correct domain.
SQLite/D1 date binding issue
BetterAuth passes Date objects for timestamp columns (createdAt, updatedAt, expiresAt). This crashes on Cloudflare D1 because D1's .bind() only accepts string | number | null | ArrayBuffer.
Do not use new Proxy to wrap D1. Instead, use a drizzle customType called epochMs for all timestamp columns. It stores epoch milliseconds as integers (same SQL type, no migration needed) but converts Date → date.getTime() in drizzle's toDriver hook before values reach D1. See the drizzle skill's "Timestamps" section for the full implementation.
const user = s.sqliteTable('user', {
createdAt: epochMs('created_at').notNull().$defaultFn(() => Date.now()),
updatedAt: epochMs('updated_at').notNull().$defaultFn(() => Date.now()),
})
export function getDb() {
return drizzle(env.DB, { schema, relations: schema.relations })
}
Why not supportsDates: false? When this flag is set, BetterAuth converts Date → toISOString() (a string). If your columns are integer (storing epoch ms), this stores ISO strings in integer columns, corrupting data and breaking sorting/comparisons.
Why not integer({ mode: 'timestamp_ms' })? This changes the TypeScript type from number to Date, requiring changes across the entire codebase. API JSON responses would serialize as ISO strings instead of epoch numbers, breaking CLI clients.
This issue is tracked in https://github.com/better-auth/better-auth/issues/8882 (PR #8913 adds supportsDates: false for SQLite but converts to ISO strings, not epoch numbers, so it doesn't help for integer timestamp schemas).
Cloudflare Workers
better-auth uses AsyncLocalStorage. Enable it in wrangler.jsonc:
{
"compatibility_flags": ["nodejs_compat"]
}
Or for just AsyncLocalStorage: ["nodejs_als"].
Bundle size: always use better-auth/minimal
For Cloudflare Workers and edge runtimes, always import from better-auth/minimal instead of better-auth. The default entrypoint bundles Kysely (~400 KB) for when no database adapter is provided. Since Cloudflare projects always use an explicit adapter (drizzle, prisma, etc.), Kysely is dead code.
import { betterAuth } from 'better-auth/minimal'
The API is identical. Plugins are imported from better-auth/plugins as usual. Saves ~400 KB from the bundle.
Social providers
Google
Always set prompt: 'select_account' so Google shows the account picker every time. Without it, users with a single Google session are silently signed in with no way to switch accounts.
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
prompt: 'select_account',
},
}
GitHub
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
}
Multiple providers can be enabled simultaneously. Each provider needs its own OAuth app credentials.
Core schema
better-auth creates and manages these tables:
- user — id, name, email, emailVerified, image, createdAt, updatedAt
- session — id, token, userId, expiresAt, ipAddress, userAgent, createdAt, updatedAt
- account — id, userId, accountId, providerId, accessToken, refreshToken, expiresAt, etc.
- verification — id, identifier, value, expiresAt, createdAt, updatedAt
Generate the Drizzle schema for these with pnpm dlx auth@latest generate. Do not define these tables manually. Plugins add additional tables (e.g. twoFactor adds a twoFactor table).
Table name customization
If your Drizzle schema uses different table names (e.g. users instead of user):
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
...schema,
user: schema.users,
},
}),
})
Or configure via modelName:
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
user: { modelName: 'users' },
session: { modelName: 'sessions' },
})
Or if all tables are plural:
drizzleAdapter(db, {
provider: 'pg',
usePlural: true,
})
Testing with vitest
Test better-auth apps by calling app.handle() directly with vitest. No browser, no build, sub-second feedback. This tests the real business logic: user creation, session validation, protected pages, server actions with auth, resource CRUD with ownership checks.
Much faster than browser e2e tests and covers the important things: auth flows, authorization guards, data isolation between users, redirect behavior after mutations.
Setup: set AUTH_DB=:memory: in vitest env so tests run against an in-memory SQLite database. Add a setup file that applies drizzle migrations before tests start. Enable the bearer() plugin so tests can authenticate with Authorization: Bearer <token> headers.
Pattern: create real users via auth.api.signUpEmail, get bearer tokens, pass them to createSpiceflowFetch(app, { headers }). Call server actions with runAction + authed request. Assert on page renders, loader data, and redirect responses.
Full working example: https://github.com/remorses/spiceflow/tree/main/example-better-auth
Use cases to test:
- Public pages render without auth (landing, login, marketing)
- Protected pages redirect unauthenticated users to login
- Protected API routes return 401 for unauthenticated requests
- Authenticated users see their own data (dashboard renders user name/email)
- Multi-step resource creation: signup → create org → redirect → dashboard → create project → verify render
- Mutations via server actions with auth (update profile, create/delete resources)
- Redirect behavior after mutations (action creates resource, redirects to its page)
- Security: unauthenticated users cannot access protected resources
- Security: users cannot access resources owned by other users (user B cannot see user A's org dashboard)
- Security: ownership checks on mutations (user B cannot create/delete projects in user A's org)
- Multiple users with separate sessions see isolated data
- Loader data contains correct values for the authenticated user