better-auth

SkillDatabases & data

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, server actions with auth, and SQLite Durable Objects (betterAuth inside the DO, getCookieCache in the Worker). ALWAYS load this skill when a project uses better-auth. Load durable-objects.md when auth tables live in a Durable Object.

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the better-auth skill

What this skill tells your AI

The instructions your AI receives, as published by remorses/opencode-config in skills/better-auth/SKILL.md and read by ahel’s review.

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:

// GOOD
const url = new URL('/api/auth', process.env.BETTER_AUTH_URL)

// BAD
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

Recommended: better-auth-drizzle-adapter (works with drizzle v0 and v1)

Always use better-auth-drizzle-adapter (npm) instead of the official @better-auth/drizzle-adapter. Three reasons:

  1. drizzle-orm v1 support. The official @better-auth/drizzle-adapter only works with drizzle-orm v0 (^0.45). It crashes on drizzle-orm v1 (1.0.0-beta) with "model 'user' was not found in the schema object". The community adapter is vendored from PR #9489 which adds relations-v2 support.

  2. SQL null bug fixed. Both the official adapter and the upstream PR code use eq(column, null) which generates column = NULL in SQL. This is never true (SQL null semantics). It silently breaks device authorization, refresh-token rotation, and any operation using { value: null } WHERE clauses. better-auth-drizzle-adapter@>=1.0.3 fixes this with isNull()/isNotNull().

  3. postgres-js deleteMany fix. The official adapter's deleteMany returns 0 on postgres-js because Result extends Array and res.length is 0 for DELETE without RETURNING. Fixed in >=1.0.4.

pnpm add better-auth better-auth-drizzle-adapter
import { betterAuth } from 'better-auth/minimal'
import { drizzleAdapter } from 'better-auth-drizzle-adapter'

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'sqlite' }), // or 'pg'
  // ...
})

Use better-auth/minimal on Cloudflare Workers to avoid bundling Kysely (~400KB). The /minimal entry strips the built-in database layer, so a drizzle adapter is required.

Source: https://github.com/remorses/better-auth-drizzle-adapter

Server config

Create src/lib/auth.ts (or lib/auth.ts). Export the auth instance as auth.

Drizzle adapter (Postgres)

// src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth-drizzle-adapter'
import { db } from 'db' // drizzle instance from your db workspace package

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
  }),
  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: {
    expiresIn: 60 * 60 * 24 * 365, // 1 year
    updateAge: 60 * 60 * 24, // refresh expiry every 1 day of activity
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },
})

Drizzle adapter (SQLite / Cloudflare D1)

import { betterAuth } from 'better-auth/minimal'
import { drizzleAdapter } from 'better-auth-drizzle-adapter'
import { db } from 'db'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'sqlite',
  }),
  secret: process.env.BETTER_AUTH_SECRET!,
  baseURL: process.env.BETTER_AUTH_URL!,
  emailAndPassword: { enabled: true },
  session: {
    expiresIn: 60 * 60 * 24 * 365, // 1 year
    updateAge: 60 * 60 * 24, // refresh expiry every 1 day of activity
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },
})

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:

Variabledevelopmentpreviewproduction
BETTER_AUTH_URLhttp://localhost:3000https://preview.example.comhttps://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)
      // Return auth responses (200, 401, 403, etc.) directly.
      // Only fall through on 404 (no matching auth endpoint).
      if (response.ok || response.status !== 404) return response
    }
    return next()
  })
  // ... rest of your routes

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'

// Session type — includes both session and user, with plugin-extended fields
type AuthSession = typeof auth.$Infer.Session | null

export const app = new Spiceflow()
  // 1. Auth middleware — forward /api/auth/* to better-auth
  .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()
  })

  // 2. Session state — resolved once per request via middleware
  .state('session', null as AuthSession)
  .use(async ({ request, state }) => {
    state.session = await auth.api.getSession({ headers: request.headers })
  })

  // 3. Session loader — exposes session to all pages and client components
  // Matched by every page/layout via wildcard. Loader data is merged,
  // so pages can add their own loaders and session is always available.
  .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) throw 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() {
  // Type-safe when SpiceflowRegister is declared in the app entry file
  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()
    // use state.session.user.id or state.session.session.userId
    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) // throws 401 if not signed in
  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()
  // Auth middleware
  .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()
  })
  // Session state
  .state('session', null as AuthSession)
  .use(async ({ request, state }) => {
    state.session = await auth.api.getSession({ headers: request.headers })
  })
  // Session loader — available to all pages and client components
  .loader('/*', ({ state }) => {
    return { session: state.session }
  })
  // Pages
  .page('/login', async ({ loaderData }) => {
    if (loaderData.session) throw redirect('/')
    const { LoginButton } = await import('./components/login-button')
    return <LoginButton />
  })
  .page('/dashboard', async ({ loaderData }) => {
    if (!loaderData.session) throw redirect('/login')
    return <div>Hello, {loaderData.session.user.name}</div>
  })
  // API routes use state.session directly
  .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

// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'

export const authClient = createAuthClient({
  // omit baseURL if client and server share the same domain
  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: '', // optional, base64 or URL
  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'

// Type safety comes from SpiceflowRegister declared in the app entry file
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) // fully typed from the route handler return type

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) {
  // handle unauthenticated
}

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 * 365, // 1 year
    updateAge: 60 * 60 * 24, // refresh expiry every 1 day of activity
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
      strategy: 'compact', // smallest size, signed, default
      // 'jwt' for JWT compatibility
      // 'jwe' for full encryption
    },
  },
})

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 },
})

Session expiration — always set to 1 year

Always set session.expiresIn to 1 year in every better-auth project. The default is only 7 days, which forces users to re-login every week. This is especially painful for CLI tools using the device flow, where re-authenticating means opening a browser and approving again.

session: {
  expiresIn: 60 * 60 * 24 * 365, // 1 year
  updateAge: 60 * 60 * 24, // refresh expiry every 1 day of activity
},

updateAge means the session expiry timestamp gets pushed forward on every day of activity. Active users effectively never expire; only truly idle sessions (no API call for a full year) will need to re-authenticate.

This applies to all session types: browser cookies, CLI device-flow bearer tokens, and any other session created by better-auth. There is no per-auth-method session config in better-auth; expiresIn is global.

If you omit expiresIn, better-auth defaults to 60 * 60 * 24 * 7 (7 days). Never rely on this default.

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(), // needed so the CLI can use the session token as a Bearer header
  ],
})

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:

  1. Check the user code is valid via auth.api.deviceVerify() with request headers
  2. Require the user to be signed in (redirect to login if not)
  3. Provide approve/deny actions via auth.api.deviceApprove / auth.api.deviceDeny

deviceVerify must receive headers: request.headers. Without headers, better-auth cannot claim the device code for the authenticated session. The subsequent deviceApprove or deviceDeny call will fail with "Device code has not been claimed by a verifying session". Make sure the user is signed in before calling deviceVerify, so the headers carry a valid session cookie.

Requires better-auth-drizzle-adapter >= 1.0.5 which implements consumeOne and incrementOne. Older versions silently fail with "Invalid device code" errors because the device plugin depends on these methods.

import { getActionRequest, 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()
  // ... auth middleware ...
  .page({
    path: '/device',
    query: devicePageQuerySchema,
    handler: async ({ request, query }) => {
      const userCode = query.user_code ?? ''
      const status = query.status

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
43
Forks
2
Last commit
Sep 2026

ahel review

  • K1binfo
    installs-packages

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Item type
skill
Key
better-auth-remorses
Source
github.com/remorses/opencode-config