
Better Auth
- 88 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
better-auth is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
Key points
- better-auth
- Security
- AI-coding skill
Better Auth by the numbers
- 88 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,054 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill better-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 88 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with security tasks during ai-assisted development?
Helps with security tasks during AI-assisted development.
Who is it for?
Best when you're working on security and need structured help with better-auth.
Skip if: Teams with no security needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with security tasks during ai-assisted development, or when better-auth is a claude code skill for security. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to better-auth: better-auth; Security; AI-coding skill.
Files
Better Auth Best Practices
Implementation and migration guide for Better Auth, the framework-agnostic TypeScript authentication and authorization library. This skill contains 42 rules organized by impact across 8 categories, derived from the official documentation and migration guides.
When to Apply
Reference these guidelines when:
- Setting up a fresh Better Auth instance (config, adapter, route handler, client)
- Wiring framework-specific integrations (Next.js App/Pages Router, SvelteKit, Hono, Express, Nuxt, Astro)
- Configuring sessions, cookies, and security (rate limit, trusted origins, password hashing)
- Adding plugins: 2FA, organization, admin, magicLink, JWT, passkey, multi-session
- Migrating from another auth library (NextAuth/Auth.js, Clerk, Auth0, Supabase Auth)
- Debugging "session is null" / "redirect_uri_mismatch" / 403 CSRF errors
- Reviewing PRs that touch
lib/auth.ts,auth-client.ts, or/api/auth/route handlers
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Setup & Configuration | CRITICAL | setup- |
| 2 | Database Adapters & Schema | CRITICAL | db- |
| 3 | API Route Handlers | CRITICAL | route- |
| 4 | Session & Cookies | HIGH | session- |
| 5 | Auth Methods & Providers | HIGH | auth- |
| 6 | Security & Hardening | HIGH | security- |
| 7 | Plugins & Extensions | MEDIUM | plugins- |
| 8 | Migration from Other Auth | MEDIUM | migrate- |
Quick Reference
1. Setup & Configuration (CRITICAL)
- `setup-secret` — Set a strong
BETTER_AUTH_SECRETper environment - `setup-base-url` — Configure an explicit
baseURLper environment - `setup-client-base-url` — Match the client
baseURLto the server - `setup-singleton` — Export a single auth instance from a server-only module
- `setup-trusted-origins` — Configure
trustedOriginsfor all non-baseURL callers
2. Database Adapters & Schema (CRITICAL)
- `db-adapter-selection` — Pick the adapter that matches your ORM
- `db-schema-generate` — Run
auth generatethen ORM migrate before every deploy - `db-additional-fields` — Extend the user schema via
additionalFields - `db-plugin-schema-customization` — Rename plugin tables via the
schemaoption - `db-database-hooks` — Use
databaseHooksfor cross-cutting logic - `db-connection-pooling` — Share one pooled DB client with the rest of your app
3. API Route Handlers (CRITICAL)
- `route-mount-catchall` — Mount the catch-all handler at
/api/auth/[...all] - `route-runtime-selection` — Use the Node.js runtime for middleware that calls
auth.api - `route-no-body-consumers` — Mount auth before any body-parsing middleware
4. Session & Cookies (HIGH)
- `session-server-vs-client` — Use
auth.api.getSessionon server,authClient.useSessionon client - `session-expiry-tuning` — Configure
expiresInandupdateAgetogether - `session-cookie-cache` — Enable
cookieCacheto cut session DB lookups - `session-cookie-attributes` — Set
sameSite,secure,partitionedfor cross-site flows - `session-cross-subdomain` — Enable
crossSubDomainCookiesfor multi-subdomain apps - `session-customsession-fields` — Use
customSessionto add computed fields
5. Auth Methods & Providers (HIGH)
- `auth-require-email-verification` — Enable
requireEmailVerificationwithsendVerificationEmail - `auth-oauth-redirect-uri` — Match OAuth
redirectURIexactly with the provider console - `auth-oauth-env-vars` — Load OAuth credentials from environment, never inline
- `auth-magic-link-setup` — Implement
sendMagicLinkbefore enabling themagicLinkplugin - `auth-client-sign-in-helpers` — Use
authClient.signIn.socialwithcallbackURL - `auth-infer-additional-fields` — Add
inferAdditionalFieldsto the client for type sync
6. Security & Hardening (HIGH)
- `security-rate-limit` — Enable
rateLimitwith persistent storage in production - `security-password-hash-interop` — Override hash function when migrating from bcrypt/argon2
- `security-revoke-on-password-reset` — Enable
revokeSessionsOnPasswordReset - `security-min-password-length` — Set
minPasswordLengthto at least 10 - `security-trusted-origins-strict` — Never wildcard
trustedOrigins
7. Plugins & Extensions (MEDIUM)
- `plugins-next-cookies-last` — Place
nextCookies()as the LAST plugin in Next.js - `plugins-two-factor-issuer` — Set
appNameas the 2FA issuer - `plugins-shared-access-control` — Define
ac+ roles once, share server/client - `plugins-pair-client-server` — Pair every server plugin with its client counterpart
- `plugins-organization-active-context` — Set active organization on session
- `plugins-jwt-when-to-use` — Use the
jwtplugin only for external service consumers - `plugins-admin-impersonation` — Use admin plugin's
impersonatemethod for support access
8. Migration from Other Auth (MEDIUM)
- `migrate-parallel-cutover` — Run Better Auth alongside legacy auth during cutover
- `migrate-oauth-account-mapping` — Map legacy OAuth identities to
accountrows - `migrate-force-allow-id` — Use
forceAllowIdto preserve existing user IDs - `migrate-nextauth-schema-mapping` — Map NextAuth v5 columns field-by-field
How to Use
For a fresh implementation, read in priority order: start with all setup- rules, then db-, then route- — these CRITICAL categories must be correct or nothing else works. After the foundation, pick the rules that match your scope: session- for cookie/expiry tuning, auth- for provider configuration, security- for production hardening.
For a migration from another auth library, read migrate-parallel-cutover first (strategy), then security-password-hash-interop (preserve user passwords), then migrate-oauth-account-mapping and migrate-nextauth-schema-mapping (data layout).
Read individual reference files for detailed explanations, incorrect vs. correct code examples, and links to the canonical Better Auth documentation.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions ordered by impact |
| assets/templates/_template.md | Template for adding new rules |
| metadata.json | Version, references, and discipline metadata |
Better Auth
Version 0.1.0 Personal May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Implementation and migration guide for Better Auth, the framework-agnostic TypeScript authentication and authorization library. Contains 42 rules across 8 categories prioritized by impact — from CRITICAL setup, database adapter, and route handler configuration to HIGH session, security, and provider concerns down to MEDIUM plugin ecosystem and migration paths from NextAuth/Auth.js, Clerk, Auth0, and Supabase Auth. Each rule includes incorrect and correct code examples covering Next.js (App and Pages Router), SvelteKit, Hono, Express, Nuxt, Astro, and React/Vue/Svelte clients.
---
Table of Contents
1. Setup & Configuration — CRITICAL
- 1.1 Configure an Explicit baseURL Per Environment — CRITICAL (prevents OAuth callback failures and incorrect redirect URLs)
- 1.2 Configure trustedOrigins for All Non-baseURL Callers — CRITICAL (prevents CSRF-blocked sign-ins from extensions, mobile apps, and preview deploys)
- 1.3 Export a Single Auth Instance from a Server-Only Module — CRITICAL (prevents duplicate database connections, type drift, and leaked secrets)
- 1.4 Match the Client baseURL to the Server baseURL — CRITICAL (prevents cross-origin auth failures and silent CORS rejections)
- 1.5 Set a Strong BETTER_AUTH_SECRET in Every Environment — CRITICAL (prevents session forgery and silent session invalidation)
2. Database Adapters & Schema — CRITICAL
- 2.1 Extend the User Schema via additionalFields, Not Raw Columns — CRITICAL (prevents type drift between database and Better Auth's session object)
- 2.2 Pick the Adapter That Matches Your ORM, Not the Default — CRITICAL (prevents runtime type errors and schema drift between Better Auth and your app)
- 2.3 Rename Plugin Tables and Columns via the schema Option, Not Migrations — HIGH (prevents Better Auth from querying wrong table/column names after manual renames)
- 2.4 Run auth generate Then Your ORM Migrate Before Every Deploy — CRITICAL (prevents all auth endpoints from 500-erroring after a plugin is added)
- 2.5 Share One Pooled Database Client With the Rest of Your App — HIGH (prevents connection exhaustion on serverless platforms (Vercel, Lambda))
- 2.6 Use databaseHooks for Cross-Cutting Logic, Not Application-Layer Wrappers — HIGH (prevents missed paths when auth events are triggered by plugins or background jobs)
3. API Route Handlers — CRITICAL
- 3.1 Mount Auth Before Any Body-Parsing Middleware — HIGH (prevents 400 "empty body" errors on POST sign-in / sign-up requests)
- 3.2 [Mount the Catch-All Handler at /api/auth/[...all] (or Framework Equivalent)](references/route-mount-catchall.md) — CRITICAL (prevents 404 on every sign-in, OAuth callback, and session request)
- 3.3 Use the Node.js Runtime for Middleware That Calls auth.api — CRITICAL (prevents Edge Runtime crashes from database adapter incompatibility)
4. Session & Cookies — HIGH
- 4.1 Configure expiresIn and updateAge Together for Sliding-Window Sessions — HIGH (prevents both premature logout and indefinite session lifetime)
- 4.2 Enable cookieCache to Cut Session Database Lookups by 99% — HIGH (100x improvement in session lookup latency on cached hits)
- 4.3 Enable crossSubDomainCookies for Multi-Subdomain Apps — HIGH (prevents users from having to re-authenticate when navigating between subdomains)
- 4.4 Set sameSite, secure, and partitioned for Cross-Site Auth Flows — HIGH (prevents browsers from silently dropping cookies in third-party contexts)
- 4.5 Use auth.api.getSession on the Server, authClient.useSession on the Client — HIGH (prevents always-null session in server components and stale data in client UI)
- 4.6 Use customSession to Add Computed Fields to the Session Response — MEDIUM-HIGH (prevents N+1 database queries from every protected page joining session→user→role)
5. Auth Methods & Providers — HIGH
- 5.1 Add inferAdditionalFields to the Client to Keep Types in Sync — HIGH (prevents type drift between server-defined user fields and client useSession types)
- 5.2 Enable requireEmailVerification and Implement sendVerificationEmail Together — HIGH (prevents account-takeover via signup with someone else's email)
- 5.3 Implement sendMagicLink Before Enabling the magicLink Plugin — HIGH (prevents passwordless sign-in from silently failing in production)
- 5.4 Load OAuth Credentials From Environment, Never Inline in Source — HIGH (prevents committed secret keys from leaking to repo history and CI logs)
- 5.5 Match OAuth redirectURI Exactly With the Provider Console — HIGH (prevents redirect_uri_mismatch on every OAuth attempt in non-default environments)
- 5.6 Use authClient.signIn.social with callbackURL Instead of Hand-Rolled Redirects — HIGH (prevents bypassing the CSRF state token and provider linking logic)
6. Security & Hardening — HIGH
- 6.1 Enable rateLimit With Persistent Storage in Production — HIGH (prevents credential-stuffing attacks and CPU exhaustion from brute-force sign-ins)
- 6.2 Enable revokeSessionsOnPasswordReset to Invalidate All Active Sessions — HIGH (prevents a leaked session from surviving a password reset by the legitimate owner)
- 6.3 Never Wildcard trustedOrigins; List Origins Explicitly — HIGH (prevents arbitrary cross-origin sites from initiating sign-in against your API)
- 6.4 Override the Password Hash Function When Migrating from bcrypt/argon2 — HIGH (prevents forcing all migrated users to reset passwords on first sign-in)
- 6.5 Set minPasswordLength to At Least 10, Not the Default 8 — MEDIUM-HIGH (prevents trivially-brute-forceable passwords from being accepted at signup)
7. Plugins & Extensions — MEDIUM
- 7.1 Define Access Control and Roles Once, Share Between Server and Client Plugins — MEDIUM-HIGH (prevents permission drift between server-enforced and client-checked roles)
- 7.2 Pair Every Server Plugin With Its Client Counterpart — MEDIUM-HIGH (prevents authClient method calls from being undefined at runtime)
- 7.3 Place nextCookies() as the LAST Plugin in Next.js Apps — HIGH (prevents sign-in from server actions silently leaving cookies unset)
- 7.4 Set appName as the 2FA Issuer for Authenticator App Display — MEDIUM (prevents users seeing "Better Auth" in their authenticator app instead of your brand)
- 7.5 Set the Active Organization on Session, Don't Pass It Per-Request — MEDIUM (prevents inconsistent active-org state across tabs and stale auth checks)
- 7.6 Use admin Plugin's impersonate Method for Support Access, Not Manual Session Creation — MEDIUM (prevents support sessions from looking indistinguishable from real user sessions in audit logs)
- 7.7 Use the jwt Plugin Only for External Service Consumers, Not Your Own Frontend — MEDIUM (prevents needlessly turning revocable session cookies into long-lived bearer tokens)
8. Migration from Other Auth — MEDIUM
- 8.1 Map Legacy OAuth Identities to Better Auth account Rows, Preserving Provider IDs — MEDIUM (prevents OAuth users from being treated as new users on first sign-in after migration)
- 8.2 Map NextAuth v5 Tables to Better Auth Schema Field-by-Field — MEDIUM (prevents silent data loss when adapter expects fields the legacy schema doesn't have)
- 8.3 Run Better Auth Alongside Legacy Auth During Cutover, Don't Big-Bang Switch — MEDIUM (prevents locking out users whose data didn't migrate cleanly)
- 8.4 Use forceAllowId During Bulk Migration to Preserve Existing User IDs — MEDIUM (prevents foreign keys in your business tables from pointing at obsolete user IDs)
---
References
1. https://www.better-auth.com/docs 2. https://www.better-auth.com/docs/installation 3. https://www.better-auth.com/docs/concepts/database 4. https://www.better-auth.com/docs/concepts/session-management 5. https://www.better-auth.com/docs/concepts/cookies 6. https://www.better-auth.com/docs/concepts/rate-limit 7. https://www.better-auth.com/docs/concepts/typescript 8. https://www.better-auth.com/docs/reference/options 9. https://www.better-auth.com/docs/reference/security 10. https://www.better-auth.com/docs/integrations/next 11. https://www.better-auth.com/docs/integrations/svelte-kit 12. https://www.better-auth.com/docs/integrations/hono 13. https://www.better-auth.com/docs/integrations/express 14. https://www.better-auth.com/docs/integrations/astro 15. https://www.better-auth.com/docs/integrations/nuxt 16. https://www.better-auth.com/docs/adapters/drizzle 17. https://www.better-auth.com/docs/adapters/prisma 18. https://www.better-auth.com/docs/authentication/email-password 19. https://www.better-auth.com/docs/authentication/google 20. https://www.better-auth.com/docs/plugins/2fa 21. https://www.better-auth.com/docs/plugins/organization 22. https://www.better-auth.com/docs/plugins/admin 23. https://www.better-auth.com/docs/plugins/jwt 24. https://www.better-auth.com/docs/plugins/magic-link 25. https://www.better-auth.com/docs/plugins/custom-session 26. https://www.better-auth.com/docs/guides/next-auth-migration-guide 27. https://www.better-auth.com/docs/guides/clerk-migration-guide 28. https://www.better-auth.com/docs/guides/auth0-migration-guide 29. https://www.better-auth.com/docs/guides/supabase-migration-guide 30. https://github.com/better-auth/better-auth
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Imperative Action Title}
{1-3 sentences explaining WHY this matters. Focus on what goes wrong without this pattern and what the cascade effect is. The model generalizes from understood reasoning, not from dictation. Don't just say "use X" — explain what happens when you don't, in concrete terms.}
Incorrect ({short label describing the problem}):
// Production-realistic counter-example, not a strawman
// Comments explaining the cost of this approach
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// ... what's wrong
});Correct ({short label describing the solution}):
// Minimal diff from the incorrect example
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// ... the right thing
});{Optional sections — include only when they add value:}
Alternative ({when applicable}):
// Different but equally valid approach for a specific contextCommon use cases:
- {Scenario 1 where this rule applies}
- {Scenario 2}
When NOT to use this pattern:
- {Edge case where the rule's tradeoff doesn't pay off}
Warning: {Gotcha worth highlighting — specific failure mode, not generic caution}
Benefits:
- {Enumerable advantage 1}
- {Enumerable advantage 2}
Reference: [{Page Title}]({URL to canonical Better Auth docs})
---
Authoring Notes
1. First tag MUST be the category prefix (setup, db, route, session, auth, security, plugins, migrate). 2. Title MUST start with an imperative verb ("Use", "Avoid", "Mount", "Configure", "Pair") — never starts with "Don't" or hedging language. 3. Both Incorrect AND Correct blocks are required. Each must have a language specifier on the code fence (typescript, text, bash, sql). 4. Impact descriptions should be quantified when possible: "2-10x improvement", "200ms savings", "O(n) to O(1)", or "prevents {specific problem}". 5. No marketing language — avoid "amazing", "powerful", "seamless", "magical", "blazing fast". The validator flags these as warnings. 6. Annotations on Incorrect/Correct headers use parenthetical style: **Incorrect (no email verification):** not **Incorrect — no email verification:**.
{
"version": "1.0.3",
"organization": "Personal",
"technology": "Better Auth",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Implementation and migration guide for Better Auth, the framework-agnostic TypeScript authentication and authorization library. Contains 42 rules across 8 categories prioritized by impact — from CRITICAL setup, database adapter, and route handler configuration to HIGH session, security, and provider concerns down to MEDIUM plugin ecosystem and migration paths from NextAuth/Auth.js, Clerk, Auth0, and Supabase Auth. Each rule includes incorrect and correct code examples covering Next.js (App and Pages Router), SvelteKit, Hono, Express, Nuxt, Astro, and React/Vue/Svelte clients.",
"references": [
"https://www.better-auth.com/docs",
"https://www.better-auth.com/docs/installation",
"https://www.better-auth.com/docs/concepts/database",
"https://www.better-auth.com/docs/concepts/session-management",
"https://www.better-auth.com/docs/concepts/cookies",
"https://www.better-auth.com/docs/concepts/rate-limit",
"https://www.better-auth.com/docs/concepts/typescript",
"https://www.better-auth.com/docs/reference/options",
"https://www.better-auth.com/docs/reference/security",
"https://www.better-auth.com/docs/integrations/next",
"https://www.better-auth.com/docs/integrations/svelte-kit",
"https://www.better-auth.com/docs/integrations/hono",
"https://www.better-auth.com/docs/integrations/express",
"https://www.better-auth.com/docs/integrations/astro",
"https://www.better-auth.com/docs/integrations/nuxt",
"https://www.better-auth.com/docs/adapters/drizzle",
"https://www.better-auth.com/docs/adapters/prisma",
"https://www.better-auth.com/docs/authentication/email-password",
"https://www.better-auth.com/docs/authentication/google",
"https://www.better-auth.com/docs/plugins/2fa",
"https://www.better-auth.com/docs/plugins/organization",
"https://www.better-auth.com/docs/plugins/admin",
"https://www.better-auth.com/docs/plugins/jwt",
"https://www.better-auth.com/docs/plugins/magic-link",
"https://www.better-auth.com/docs/plugins/custom-session",
"https://www.better-auth.com/docs/guides/next-auth-migration-guide",
"https://www.better-auth.com/docs/guides/clerk-migration-guide",
"https://www.better-auth.com/docs/guides/auth0-migration-guide",
"https://www.better-auth.com/docs/guides/supabase-migration-guide",
"https://github.com/better-auth/better-auth"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Setup & Configuration (setup)
Impact: CRITICAL Description: Missing or misconfigured secret, baseURL, or trusted origins breaks every downstream auth operation — wrong BETTER_AUTH_SECRET invalidates sessions, missing baseURL breaks OAuth redirects, and origin mismatches block all cross-origin requests.
2. Database Adapters & Schema (db)
Impact: CRITICAL Description: The adapter and schema connect the entire auth surface to your data layer — wrong adapter or stale schema causes silent runtime failures on every sign-in, session lookup, and account link, and plugin tables go missing without an explicit generate step.
3. API Route Handlers (route)
Impact: CRITICAL Description: Better Auth ships endpoints as a single handler that must be mounted correctly per framework — unmounted or wrongly-shaped routes return 404 on every auth request, and missing nextCookies() or middleware that consumes the body breaks server actions and session cookie setting.
4. Session & Cookies (session)
Impact: HIGH Description: Session retrieval, cookie attributes, and refresh strategy determine whether authenticated requests are honored — using authClient.getSession on the server returns null, wrong sameSite/secure breaks cross-site flows, and missing cookieCache floods the database with session lookups.
5. Auth Methods & Providers (auth)
Impact: HIGH Description: Email/password, OAuth, and magic link flows each have their own correctness gates — missing sendVerificationEmail, mismatched redirectURI, or skipped requireEmailVerification either silently disables features or opens account-takeover paths.
6. Security & Hardening (security)
Impact: HIGH Description: CSRF defense, rate limiting, password hash interop, and secret hygiene are the difference between a credible auth deployment and a compromise vector — Better Auth's defaults are safe, but production deployments need explicit trustedOrigins, persistent rate-limit storage, and revocation policies.
7. Plugins & Extensions (plugins)
Impact: MEDIUM Description: The plugin ecosystem (2FA, organization, admin, JWT, stripe) extends the core auth surface — most failures come from plugin ordering (nextCookies must be last), missing client/server plugin pairs, and inconsistent access-control definitions across the boundary.
8. Migration from Other Auth (migrate)
Impact: MEDIUM Description: Cross-provider migration patterns (cutover strategy, OAuth identity mapping, ID preservation) plus one NextAuth-specific schema migration. Provider-specific concerns from Clerk/Auth0/Supabase live across security-password-hash-interop (bcrypt/scrypt interop) and the official Better Auth migration guides linked from each rule — these rules cover the structural patterns common to all migrations.
Use authClient.signIn.social with callbackURL Instead of Hand-Rolled Redirects
It's tempting to skip the client SDK and window.location.href = "/api/auth/sign-in/social?provider=github" directly. This skips the CSRF state token Better Auth generates, the deep-link callbackURL parameter validation, and the linkAccount path that connects new OAuth identities to existing users. The result: OAuth completes but the user lands on a generic page, the state token is missing (request rejected), or accounts that should merge stay separate.
Incorrect (hand-rolled redirect to the auth endpoint):
"use client";
export function SignInButton() {
return (
<button
onClick={() => {
window.location.href = "/api/auth/sign-in/social?provider=github&redirect=/dashboard";
// ↑ no state token, no callbackURL validation, no link-account handling
}}
>
Sign in with GitHub
</button>
);
}Correct (signIn.social with callbackURL):
"use client";
import { authClient } from "@/lib/auth-client";
export function SignInButton() {
return (
<button
onClick={async () => {
await authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard", // where to land after success
errorCallbackURL: "/sign-in?err=1", // where to land on failure
newUserCallbackURL: "/welcome", // distinguishes first-time users
});
}}
>
Sign in with GitHub
</button>
);
}Correct (email/password with structured error handling):
const { data, error } = await authClient.signIn.email({
email,
password,
callbackURL: "/dashboard",
});
if (error) {
// Structured: error.code === "INVALID_EMAIL_OR_PASSWORD" | "EMAIL_NOT_VERIFIED" | ...
toast.error(error.message);
return;
}
// data.user is typed including any additionalFieldsBenefits:
- The SDK adds the CSRF token, normalizes the response, and surfaces typed errors.
callbackURLis validated againsttrustedOrigins— protects against open-redirect bugs.newUserCallbackURLlets you route first-time sign-ins to an onboarding flow without checkingcreatedAtin the page.
Reference: Better Auth — Client
Add inferAdditionalFields to the Client to Keep Types in Sync
When you extend the user schema via additionalFields on the server, the columns are created and the runtime API returns them — but the client SDK doesn't know about them by default. authClient.useSession().data.user.role shows up as unknown or doesn't exist at the type level, and every component reaches for as any to access the field. inferAdditionalFields reads the auth instance type at compile time and inflates the client types to match — no schema duplication, no runtime cost.
Incorrect (server has additionalFields, client types are stale):
// lib/auth.ts
export const auth = betterAuth({
user: {
additionalFields: {
role: { type: ["user", "admin"], required: false },
tenantId: { type: "string", required: false },
},
},
});// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();// component.tsx
const { data } = authClient.useSession();
const role = data?.user.role; // ← TS error: Property 'role' does not existCorrect (server type imported via inferAdditionalFields):
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "@/lib/auth"; // type-only import — server bundle stays out of client
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()],
});const { data } = authClient.useSession();
const role = data?.user.role; // typed as "user" | "admin" | undefinedAlternative (cross-package monorepo without direct import):
// When the server auth lives in a different package, declare the shape inline
import { inferAdditionalFields } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
inferAdditionalFields({
user: {
role: { type: "string" },
tenantId: { type: "string" },
},
}),
],
});Implementation: Use import type (not a value import) so bundlers tree-shake the server auth instance entirely. The plugin only uses the type information at compile time.
Implement sendMagicLink Before Enabling the magicLink Plugin
The magicLink plugin issues a passwordless one-time URL — Better Auth generates the URL and token, but you own the delivery via the sendMagicLink callback. The plugin will accept being initialized with an empty callback (it doesn't throw at startup), so it's easy to ship a deployment where authClient.signIn.magicLink({ email }) returns success and the user never receives an email. Always wire the callback to a real transactional sender, log delivery failures, and consider rate-limiting sends per email.
Incorrect (plugin enabled with empty/no-op callback):
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
magicLink({
sendMagicLink: async () => { /* TODO: implement */ }, // ← never sends
}),
],
});Correct (real send + error propagation):
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
import { sendEmail } from "@/lib/email";
export const auth = betterAuth({
plugins: [
magicLink({
sendMagicLink: async ({ email, url, token }) => {
try {
await sendEmail({
to: email,
subject: "Your sign-in link",
html: `<a href="${url}">Sign in to Example</a>`,
text: `Sign in: ${url}\nThis link expires in 5 minutes.`,
});
} catch (err) {
// Better Auth will surface this as a 500 to the client — fail loud, don't swallow
console.error("[magic-link] delivery failed", { email, err });
throw err;
}
},
expiresIn: 60 * 5, // 5 minutes — short window for passwordless tokens
}),
],
});// Client side — pair with the matching client plugin
import { createAuthClient } from "better-auth/react";
import { magicLinkClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [magicLinkClient()],
});
await authClient.signIn.magicLink({ email: "user@example.com", callbackURL: "/dashboard" });Common use cases:
- Add per-email rate-limit on top of Better Auth's global limit — "1 link per email per minute" prevents inbox spamming when users click "send again" repeatedly.
- For B2B SaaS, allow-list send-to domains so test accounts can't burn quota with random external mail.
Reference: Better Auth — magicLink Plugin
Load OAuth Credentials From Environment, Never Inline in Source
OAuth clientSecret values are bearer credentials — anyone holding one can impersonate your application to the provider and harvest user tokens. Inlining them in lib/auth.ts for "convenience" exposes them in: repository history (even after git rm), CI logs that print configs, the bundled JS if auth.ts is ever imported by a client component, error stack traces, and any logging middleware that dumps auth.options. The 1-line cost of process.env.GOOGLE_CLIENT_SECRET! is non-negotiable.
Incorrect (hardcoded secret):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
socialProviders: {
google: {
clientId: "987654321-abcdef.apps.googleusercontent.com",
clientSecret: "GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ", // ← committed to git
},
},
});Correct (env-loaded with explicit non-null assertion):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
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!,
},
},
});# .env.local (gitignored)
GOOGLE_CLIENT_ID=987654321-abcdef.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-aBcDeFgHiJkLmNoPqRsTuVwXyZ
GITHUB_CLIENT_ID=Iv1.abc123
GITHUB_CLIENT_SECRET=ghp_xxxxxxxxxxxxImplementation (with runtime validation):
import { z } from "zod";
const env = z.object({
GOOGLE_CLIENT_ID: z.string().min(1),
GOOGLE_CLIENT_SECRET: z.string().min(1),
GITHUB_CLIENT_ID: z.string().min(1),
GITHUB_CLIENT_SECRET: z.string().min(1),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
}).parse(process.env);
export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
socialProviders: {
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
},
});Warning: If a secret has been committed, rotate it immediately — git rm and force-push do not remove it from forks, CI caches, or git hosting backups.
Reference: Better Auth — Social Providers Setup
Match OAuth redirectURI Exactly With the Provider Console
OAuth providers (Google, GitHub, Apple, Discord, Facebook) compare the redirect_uri parameter byte-for-byte with the URLs registered in their developer console. Any mismatch — trailing slash, http vs https, different port, preview deploy URL — produces a redirect_uri_mismatch error before the user ever sees a sign-in screen. Better Auth defaults redirectURI to {baseURL}/api/auth/callback/{provider}; if your baseURL is correct this works out of the box, but multi-environment deployments need either explicit redirectURI or every URL registered upstream.
Incorrect (default redirectURI but provider only has prod URL registered):
// lib/auth.ts on a preview deploy at https://pr-42.preview.example.com
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL, // = preview URL
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Google Cloud Console only has https://app.example.com/api/auth/callback/google registered
// → redirect_uri_mismatch on every preview
},
},
});Correct (single registered prod URL, force redirectURI to match):
export const auth = betterAuth({
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: "https://app.example.com/api/auth/callback/google", // always prod
},
},
});Alternative (register every callback URL in the provider console):
Google Cloud Console → OAuth 2.0 Client → Authorized redirect URIs:
https://app.example.com/api/auth/callback/google
https://staging.example.com/api/auth/callback/google
https://*.preview.example.com/api/auth/callback/google ← if provider supports wildcards
http://localhost:3000/api/auth/callback/google// Default redirectURI now resolves correctly for each environment
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
});Warning: Google does not support wildcards in production redirect URIs. For per-PR previews against Google, use the forced-prod-URI approach and pass the original location through state, or use a separate Google project for staging.
Reference: Better Auth — Social Providers
Enable requireEmailVerification and Implement sendVerificationEmail Together
Without email verification, anyone can sign up with victim@example.com, set a password, and own the account until the real owner notices. requireEmailVerification blocks sign-in until the email is confirmed — but if you set the flag without implementing sendVerificationEmail, Better Auth has no way to deliver the verification link and users sit blocked forever. The two settings are a pair: enabling one without the other is a bug. Setting sendOnSignIn: true ensures the verification email also sends on every blocked sign-in attempt, not just once at signup.
Incorrect (verification required but no send function):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
requireEmailVerification: true, // blocks login
},
// emailVerification missing → no email ever sent → users locked out
});Incorrect (send function but verification not required):
export const auth = betterAuth({
emailAndPassword: { enabled: true }, // unverified users can still sign in
emailVerification: {
sendVerificationEmail: async ({ user, url }) => { /* ... */ },
},
});Correct (paired configuration):
import { betterAuth } from "better-auth";
import { sendEmail } from "@/lib/email";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false, // don't grant a session until verified
sendResetPassword: async ({ user, url }) => {
await sendEmail({ to: user.email, subject: "Reset your password", text: `Reset: ${url}` });
},
},
emailVerification: {
sendOnSignIn: true, // resend on every blocked sign-in
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your email",
text: `Verify your account: ${url}`,
});
},
},
});Common use cases:
- Use a transactional provider (Resend, Postmark, SendGrid) — not your application SMTP — so verification mails don't get throttled with marketing email.
- Render the verification link as a button in HTML, but always include the raw URL as text fallback (some clients strip buttons).
Reference: Better Auth — Email Verification
Pick the Adapter That Matches Your ORM, Not the Default
Better Auth ships a built-in Kysely SQL adapter by default, plus first-class adapters for Drizzle, Prisma, and MongoDB. If your application already uses Drizzle or Prisma, using the matching adapter is essential — it shares the same client, connection pool, and TypeScript types, and lets you run migrations through your existing toolchain. Using the wrong adapter (or the raw Pool default when you have an ORM) creates two parallel schema definitions that drift, two connection pools competing for limits, and types that don't match what the rest of your code sees.
Incorrect (Drizzle app using raw Pool default):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
// App uses Drizzle, but auth bypasses it — duplicates schema and pool
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
});Correct (Drizzle adapter sharing the app's db instance):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db"; // your existing Drizzle instance
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg", // or "mysql" or "sqlite"
}),
});Alternative (Prisma app):
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql", // or "mysql", "sqlite", ...
}),
});Alternative (MongoDB):
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { client } from "@/db";
export const auth = betterAuth({
database: mongodbAdapter(client),
});Reference: Better Auth — Adapters
Extend the User Schema via additionalFields, Not Raw Columns
Adding a column directly to the user table — without telling Better Auth about it — leaves the field invisible to the auth API, the session response, and the client types. additionalFields is the canonical extension point: it generates the column when you run auth generate, includes the field on the user object returned by auth.api.getSession, and (paired with inferAdditionalFields on the client) flows the type through to useSession(). Bypassing it forces hand-rolled joins everywhere a downstream component needs the extra field.
Incorrect (manual column, invisible to auth):
-- Migration written by hand
ALTER TABLE "user" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
ALTER TABLE "user" ADD COLUMN "tenant_id" UUID;// session.user.role is undefined at the type level — must query separately
const session = await auth.api.getSession({ headers });
// @ts-expect-error — role isn't on the inferred user type
const role = session?.user.role;Correct (declare via additionalFields, regenerate schema):
// lib/auth.ts
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
additionalFields: {
role: {
type: ["user", "admin"],
required: false,
defaultValue: "user",
input: false, // user can't set this themselves on signup
},
tenantId: {
type: "string",
required: false,
},
},
},
});// lib/auth-client.ts (client-side type sync)
import { createAuthClient } from "better-auth/react";
import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "@/lib/auth";
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()],
});// Now typed correctly on both server and client
const session = await auth.api.getSession({ headers });
const role = session?.user.role; // typed as "user" | "admin" | undefinedWhen NOT to use additionalFields:
- Fields that don't belong on the user identity (e.g., profile preferences with their own lifecycle). Use a separate
profiletable with a foreign key touser.id.
Reference: Better Auth — TypeScript: Additional Fields
Share One Pooled Database Client With the Rest of Your App
In serverless deployments (Vercel Functions, AWS Lambda, Cloudflare Workers with hyperdrive), each cold start opens a new database connection. If your auth instance has its own Pool separate from the app's ORM client, you double the open connections per instance — and at concurrency limits you hit too many connections errors that look like random auth failures. The fix is to pass the same client both Better Auth and your application code use.
Incorrect (Drizzle app, Better Auth opens a second Pool):
// db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool);// lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }), // ← second pool
});Correct (Better Auth uses the same Drizzle client):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
});With Prisma (use the singleton pattern that survives HMR):
// db.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "@/db";
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
});Common use cases:
- For very high concurrency on traditional Postgres, layer a connection pooler (PgBouncer, Neon, Supabase Pooler) in front — Better Auth and your ORM both go through it.
Reference: Better Auth — Database: Connection Pooling
Use databaseHooks for Cross-Cutting Logic, Not Application-Layer Wrappers
When you need to run logic on every user creation (provision a Stripe customer, send a welcome email, mirror to an analytics service) the obvious instinct is to wrap your sign-up route handler. But Better Auth creates users from multiple paths: email sign-up, OAuth callbacks, passwordless email, anonymous → permanent upgrade, admin-created users, and plugin-driven flows. Wrapping one route misses the others. databaseHooks runs at the data-layer boundary, capturing every path uniformly.
Incorrect (wrapping only the explicit sign-up route):
// app/api/sign-up/route.ts — wraps only this one path
export async function POST(req: Request) {
const result = await auth.handler(req);
if (result.ok) {
await provisionStripeCustomer(/* ... */); // ← skipped on OAuth, passwordless email, etc.
}
return result;
}Correct (databaseHooks runs for every creation path):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { stripe } from "@/lib/stripe";
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user) => {
// Normalize data before insert
return {
data: {
...user,
firstName: user.name?.split(" ")[0],
lastName: user.name?.split(" ").slice(1).join(" "),
},
};
},
after: async (user) => {
// Runs for ALL creation paths: email/password, OAuth, passwordless email, admin-created
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
});
await db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, user.id));
},
},
update: {
before: async (data, ctx) => {
// ctx.context.session is the actor doing the update
if (ctx.context.session) {
await audit.log({ actor: ctx.context.session.userId, action: "user.update", data });
}
return { data };
},
},
},
session: { /* session lifecycle hooks */ },
account: { /* OAuth account link/unlink hooks */ },
},
});Warning: Throwing inside a before hook cancels the operation; throwing inside an after hook leaves the user/session created and the side effect failed. Wrap after hooks in try/catch + retry queue for at-least-once delivery semantics.
Reference: Better Auth — Database Hooks
Rename Plugin Tables and Columns via the schema Option, Not Migrations
Each plugin (twoFactor, organization, admin, passkey, etc.) introduces its own tables and columns with default names like twoFactor, twoFactorEnabled, organizationId. If your codebase uses snake_case columns (two_factor_enabled) or a different table prefix (auth_*), renaming via raw SQL migration leaves Better Auth still issuing queries against the original names. Plugin options accept a schema override that tells the library what names to use; this is the only safe way to align with your convention.
Incorrect (renaming columns via migration, plugin queries break):
ALTER TABLE "user" RENAME COLUMN "twoFactorEnabled" TO "two_factor_enabled";
ALTER TABLE "user" RENAME COLUMN "twoFactorSecret" TO "two_factor_secret";// twoFactor() still issues SELECT "twoFactorEnabled" FROM "user" — fails
plugins: [twoFactor()];Correct (tell the plugin what your column names are):
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
twoFactor({
schema: {
user: {
fields: {
twoFactorEnabled: "two_factor_enabled",
secret: "two_factor_secret",
},
},
},
}),
],
});// Same pattern works for the core user/session/account/verification tables
export const auth = betterAuth({
user: {
modelName: "users", // table renamed: user → users
fields: {
email: "email_address", // column renamed
emailVerified: "is_email_verified",
},
},
session: {
modelName: "user_sessions",
},
});Implementation: Always run auth generate after changing schema/fields/modelName — the CLI produces the correctly-named columns for your ORM.
Reference: Better Auth — Database: Plugins Schema
Run auth generate Then Your ORM Migrate Before Every Deploy
Better Auth's schema is owned by the library and discovered from your auth config — including any plugins you've added (2FA, organization, admin, passkey each add tables and columns). The auth generate CLI inspects your config and writes a schema fragment (Drizzle/Prisma) or raw SQL (Kysely). You then apply it with your ORM's migration tool. Skipping generate after adding a plugin means the plugin's endpoints fail at runtime; skipping the migrate step means production starts without the tables.
Incorrect (adding a plugin without regenerating schema):
// lib/auth.ts — added twoFactor plugin
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
// ...
plugins: [twoFactor()], // adds twoFactorEnabled, secret, backupCodes columns
});# Forgot to regenerate — twoFactor table missing in prod
git push origin mainCorrect (regenerate + migrate as part of CI/CD):
# 1. Regenerate Better Auth schema from current config
npx @better-auth/cli@latest generate
# 2. Apply with your ORM
# Drizzle:
npx drizzle-kit generate
npx drizzle-kit migrate
# Prisma:
npx prisma migrate dev --name add_two_factor
# (in CI) npx prisma migrate deploy
# Kysely (built-in adapter only):
npx @better-auth/cli@latest migrateImplementation (package.json scripts):
{
"scripts": {
"auth:generate": "better-auth generate",
"db:migrate": "drizzle-kit migrate",
"predeploy": "npm run auth:generate && drizzle-kit generate && npm run db:migrate"
}
}Warning: better-auth migrate only works with the built-in Kysely adapter. For Prisma/Drizzle, you must use the ORM's own migration command after generate.
Reference: Better Auth — CLI
Use forceAllowId During Bulk Migration to Preserve Existing User IDs
Better Auth's adapters generate new IDs by default on insert — safe for production traffic, dangerous during migration. Your existing data already has foreign keys (invoice.userId, audit_log.actor_id, team_member.user_id) pointing at the legacy IDs. If the migration script lets Better Auth assign new IDs, every business table now points at non-existent users. The forceAllowId: true flag tells the adapter to honor the id you provide in the data payload, preserving referential integrity.
Incorrect (default insert assigns new IDs, foreign keys break):
// Migration script
for (const legacyUser of legacyUsers) {
await ctx.adapter.create({
model: "user",
data: {
id: legacyUser.id, // ← ignored; adapter generates fresh ID
email: legacyUser.email,
// ...
},
});
}
// Now user has id="new-uuid-1", but invoice.userId still says "legacy-id-1" → orphanedCorrect (forceAllowId preserves legacy IDs):
// Migration script
for (const legacyUser of legacyUsers) {
await ctx.adapter.create({
model: "user",
data: {
id: legacyUser.id, // honored because forceAllowId
email: legacyUser.email,
name: legacyUser.name,
emailVerified: legacyUser.email_verified,
createdAt: new Date(legacyUser.created_at),
updatedAt: new Date(legacyUser.updated_at),
},
forceAllowId: true, // ← preserve ID exactly as given
});
}
// invoice.userId references match new user.id → all foreign keys intactImplementation (verify IDs survived the round trip):
async function verifyMigration(sample: LegacyUser[]) {
const orphans: string[] = [];
for (const u of sample) {
const migrated = await db.query.user.findFirst({ where: eq(user.id, u.id) });
if (!migrated) orphans.push(u.id);
}
if (orphans.length > 0) throw new Error(`${orphans.length} users lost their IDs`);
}When NOT to use forceAllowId:
- For normal application code paths (sign-up, social link). Reserve it for one-shot migration scripts where you've audited the input.
- If your legacy IDs collide with Better Auth's ID format expectations (e.g., legacy uses integers, Better Auth uses UUIDs and you have a UUID column type), normalize the format first rather than forcing a mismatch.
Reference: Better Auth — Auth0 Migration: Adapter create with forceAllowId
Map NextAuth v5 Tables to Better Auth Schema Field-by-Field
NextAuth/Auth.js v5 and Better Auth use similar but non-identical schemas. The table names match (user, session, account, verification) but several columns differ in name, type, or nullability — and silently inserting the new schema on top of the old one drops the fields that don't match. The migration must explicitly map each Auth.js column to its Better Auth equivalent. The most common mismatches:
| Auth.js column | Better Auth column | Notes |
|---|---|---|
user.emailVerified (Date or null) | user.emailVerified (boolean) | Type change — null/new Date(...) → false/true |
account.access_token | account.accessToken | snake_case → camelCase |
account.expires_at (unix seconds) | account.accessTokenExpiresAt (Date) | format change |
account.session_state | (removed) | not used by Better Auth |
session.sessionToken | session.token | renamed |
verificationToken (table) | verification (table) | renamed |
Incorrect (rename only the table, leave columns mismatched):
ALTER TABLE "verificationToken" RENAME TO "verification";
-- Better Auth queries verification.identifier but the column is named identifier too — looks fine
-- but session.sessionToken still queried as session.token → reads return null on every sessionCorrect (explicit per-column migration):
-- user: emailVerified Date → boolean
ALTER TABLE "user" ADD COLUMN "emailVerified_new" BOOLEAN NOT NULL DEFAULT false;
UPDATE "user" SET "emailVerified_new" = ("emailVerified" IS NOT NULL);
ALTER TABLE "user" DROP COLUMN "emailVerified";
ALTER TABLE "user" RENAME COLUMN "emailVerified_new" TO "emailVerified";
-- account: rename + reshape OAuth token fields
ALTER TABLE "account" RENAME COLUMN "access_token" TO "accessToken";
ALTER TABLE "account" RENAME COLUMN "refresh_token" TO "refreshToken";
ALTER TABLE "account" ADD COLUMN "accessTokenExpiresAt" TIMESTAMP;
UPDATE "account" SET "accessTokenExpiresAt" = to_timestamp("expires_at") WHERE "expires_at" IS NOT NULL;
ALTER TABLE "account" DROP COLUMN "expires_at";
ALTER TABLE "account" DROP COLUMN "session_state";
-- session: rename column
ALTER TABLE "session" RENAME COLUMN "sessionToken" TO "token";
-- verification token table rename
ALTER TABLE "verificationToken" RENAME TO "verification";Alternative (run a script through the adapter rather than raw SQL):
// Safer: use the adapter so you get type checking and Better Auth's own normalization
for (const legacy of await legacyDb.query.user.findMany()) {
await ctx.adapter.create({
model: "user",
data: {
id: legacy.id,
email: legacy.email,
emailVerified: legacy.emailVerified !== null, // Date → boolean
name: legacy.name,
image: legacy.image,
},
forceAllowId: true,
});
}Implementation: Run npx @better-auth/cli@latest generate against your target config first to see the exact schema Better Auth expects, then diff against your legacy schema to identify every mismatch. Don't trust the table names matching.
Reference: Better Auth — NextAuth Migration Guide
Map Legacy OAuth Identities to Better Auth account Rows, Preserving Provider IDs
Better Auth stores each OAuth identity as a row in the account table keyed on (providerId, accountId) — where accountId is the OAuth provider's user ID (Google's sub, GitHub's id, etc.). NextAuth/Auth.js calls these "accounts" too, Auth0 calls them "identities", Clerk calls them "external accounts" — schemas differ but the concept is the same. If your migration creates user rows but doesn't populate account rows, OAuth users land on the sign-in page, click "Sign in with Google," and get a NEW user — duplicated, separate from their old data.
Incorrect (migrate users only, skip accounts):
// Migration script
for (const oldUser of legacyUsers) {
await db.insert(user).values({
id: oldUser.id,
email: oldUser.email,
name: oldUser.name,
});
// account rows not created → next Google sign-in creates duplicate user
}Correct (preserve OAuth identity → account mapping):
// Migration script — for an Auth0 export
for (const auth0User of legacyUsers) {
// 1. Create the user row
await db.insert(user).values({
id: auth0User.user_id,
email: auth0User.email,
emailVerified: auth0User.email_verified ?? false,
name: auth0User.name,
image: auth0User.picture,
createdAt: new Date(auth0User.created_at),
updatedAt: new Date(auth0User.updated_at),
});
// 2. Create one account row per OAuth identity
for (const identity of auth0User.identities ?? []) {
const providerId = identity.provider === "auth0" ? "credential" : identity.provider;
await db.insert(account).values({
id: `${auth0User.user_id}|${providerId}|${identity.user_id}`,
userId: auth0User.user_id,
providerId,
accountId: identity.user_id, // ← MUST match what the provider returns on next OAuth sign-in
accessToken: identity.access_token,
refreshToken: identity.refresh_token,
scope: identity.scope,
idToken: identity.id_token,
// password is null for OAuth; populated only for credential provider
});
}
// 3. Email/password identity → 'credential' account with hashed password
if (auth0User.password_hash) {
await db.insert(account).values({
id: `${auth0User.user_id}|credential`,
userId: auth0User.user_id,
providerId: "credential",
accountId: auth0User.email,
password: auth0User.password_hash, // verify in custom verify() — see security-password-hash-interop
});
}
}Implementation (verification step):
// After migration, dry-run sign-in for a sample of users
const sample = legacyUsers.slice(0, 100);
for (const u of sample) {
const account = await db.query.account.findFirst({
where: and(eq(account.providerId, "google"), eq(account.accountId, u.google_sub)),
});
if (!account) console.error(`Missing OAuth account for ${u.email}`);
}Reference: Better Auth — Auth0 Migration Guide
Run Better Auth Alongside Legacy Auth During Cutover, Don't Big-Bang Switch
Migrating from NextAuth/Auth.js, Clerk, Auth0, or Supabase Auth involves moving user rows, OAuth account links, password hashes, and active sessions — any of which can have data quality issues that surface only on first sign-in attempt. Cutting traffic from old auth → new auth atomically means every issue becomes a customer-facing outage. The safer pattern is dual-write/dual-read: both systems operate, Better Auth becomes the source of truth gradually, and rollback is a config change rather than a restore-from-backup.
Incorrect (big-bang cutover):
Day N-1: NextAuth running, all users sign in fine
Day N: Deploy → NextAuth removed, Better Auth replaces /api/auth
↓
5% of users hit a bug: case-sensitive email mismatch, missing OAuth link, ...
↓
5% of users are locked out, support gets buried, rollback is hardCorrect (parallel deploy with feature flag, gradual cutover):
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth"; // Better Auth instance
import { handlers as nextAuth } from "@/lib/next-auth"; // legacy
import { toNextJsHandler } from "better-auth/next-js";
const betterAuthHandler = toNextJsHandler(auth);
export async function GET(req: Request) {
// Cohort routing — flag controls which users go to new auth
if (await shouldUseBetterAuth(req)) return betterAuthHandler.GET(req);
return nextAuth.GET(req);
}
export async function POST(req: Request) {
if (await shouldUseBetterAuth(req)) return betterAuthHandler.POST(req);
return nextAuth.POST(req);
}Implementation (cohort cutover progression):
Week 1: 1% of new sign-ups → Better Auth. Old users stay on legacy.
Week 2: 10% of new + 5% of returning. Watch error rate, support tickets.
Week 3: 50% / 25%. Backfill failed migrations.
Week 4: 100% of new, 100% of returning. Legacy in shadow mode (dual-write only).
Week 6: Disable legacy writes.
Week 8: Remove legacy auth code.Common use cases:
- Migrate sign-up traffic first (no legacy state to honor) — gets you Better Auth in production faster.
- Backfill on read: when a legacy user signs in, lazy-migrate their row into Better Auth's schema.
- Keep legacy hash format for old users (see security-password-hash-interop) — don't force password resets.
Warning: Plan the cutover BEFORE writing migration scripts. Knowing the rollback story shapes what "successful migration" means — you cannot roll back schema changes that have been live for two weeks.
Reference: Better Auth — Migration Guides
Use admin Plugin's impersonate Method for Support Access, Not Manual Session Creation
Support and admin teams routinely need to "log in as" a user to reproduce a bug or fix a state issue. The wrong pattern is to create a session row directly in the database for the target user — it works, but audit logs show "user X signed in from admin's IP" with no trace that an admin was the actor, and every downstream system sees a normal user session. The admin plugin's impersonation flow creates a session that carries both the impersonator's ID and the target's ID, so audit trails, suspicious-activity detection, and revocation policies can treat it correctly.
Incorrect (manually creating a session for the target user):
// Admin clicks "Login as user" in internal tool
await db.insert(session).values({
userId: targetUserId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
token: generateToken(),
});
// No record of WHO impersonated → audit log shows the target user signed inCorrect (admin plugin impersonation):
// lib/auth.ts (server)
import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [admin({ /* ac, roles */ })],
});// Internal admin tool
import { authClient } from "@/lib/auth-client";
// Only callable when authClient session has admin role
const { data } = await authClient.admin.impersonateUser({ userId: "target-user-id" });
// session is now the target user, but session.impersonatedBy = <admin user id>// Server-side audit middleware
const session = await auth.api.getSession({ headers });
if (session?.session.impersonatedBy) {
await audit.log({
event: "impersonated_action",
impersonator: session.session.impersonatedBy,
user: session.user.id,
request: req.url,
});
}Implementation (UX considerations):
- Always render an obvious "Impersonating <name> — stop" banner in the UI when
session.impersonatedByis set. - Use
authClient.admin.stopImpersonating()to return to the original admin session in one step. - Restrict the impersonate permission to support team roles, not all admins (least privilege).
Reference: Better Auth — Admin Plugin
Use the jwt Plugin Only for External Service Consumers, Not Your Own Frontend
Better Auth's primary session model is database-backed: every session is a row, sign-out deletes it, and revocation is instant. The jwt plugin issues stateless signed tokens — useful when you have a downstream service that can't share your database (mobile backends, microservices, third-party API consumers). It's not a "lighter alternative" to sessions: JWTs can't be revoked before expiry without a denylist, leak in browser history if put in URLs, and don't get the benefits of cookieCache. Use it as an addition for specific consumers, not a replacement.
Incorrect (JWT plugin used for first-party web client just because it sounds modern):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [jwt()], // for the web frontend → sign-out can't revoke until token expires
});// frontend storing JWT in localStorage
const { data } = await authClient.signIn.email({ email, password });
localStorage.setItem("jwt", data.token); // ← XSS-readable, can't be revokedCorrect (web frontend uses session cookies; JWT only for an external service):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt({
jwks: { /* key rotation config */ },
jwt: {
expirationTime: "15m", // SHORT — JWTs can't be revoked, keep window tight
audience: "downstream-api.example.com",
},
}),
],
});// Web frontend: session cookies (default) — revocable, no JWT touched
const { data } = await authClient.signIn.email({ email, password });
// Backend issues a JWT only when forwarding to the downstream service
const session = await auth.api.getSession({ headers });
const token = await auth.api.getToken({ session }); // short-lived JWT
const downstreamResp = await fetch("https://downstream-api.example.com/...", {
headers: { Authorization: `Bearer ${token}` },
});Alternative (mobile native app — JWT is appropriate; cookies aren't):
// React Native client can't use cookies easily; JWT in keychain is the right tradeoff
const { token } = await authClient.getToken();
await SecureStore.setItemAsync("auth-token", token);Warning: If you must use JWT for a long-lived first-party client, pair with a server-side denylist (per-user "minimum-issued-at" timestamp) so security events can invalidate all outstanding tokens. Without that, "sign out everywhere" is impossible until natural expiry.
Reference: Better Auth — JWT Plugin
Place nextCookies() as the LAST Plugin in Next.js Apps
Next.js Server Actions run on the server but cannot set cookies via the Response object — they must call cookies().set(...) from next/headers. Better Auth's nextCookies() plugin intercepts cookie-setting operations and rewrites them to the Next API. It must run after every other plugin so it sees the final set of cookies each plugin wants to write. Placing it earlier (or omitting it entirely) makes signIn.email/signUp.email/signOut calls from server actions return success — but the browser never receives the session cookie, so the user appears unauthenticated on the next request.
Incorrect (missing nextCookies, or not last):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { twoFactor, organization } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
nextCookies(), // ← positioned first; later plugins set cookies it never sees
twoFactor(),
organization(),
],
});Correct (nextCookies LAST):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { twoFactor, organization } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
twoFactor(),
organization(),
nextCookies(), // ← always last in the array
],
});When NOT needed:
- If you never call
auth.api.signInEmail,signUpEmail, etc. from a server action (only from a route handler that returns its ownResponse), this plugin isn't required. But adding it is harmless and future-proofs against new server-action call sites.
Warning: This is Next.js-specific. SvelteKit, Astro, and Hono have their own cookie-setting paths that don't need this plugin.
Reference: Better Auth — Next.js: Server Action Cookies
Set the Active Organization on Session, Don't Pass It Per-Request
In multi-tenant apps using the organization plugin, every request needs to know "which org is the user acting as?" The wrong pattern is to pass ?orgId=... from the URL or sniff a cookie in every API handler — it drifts across tabs (user switches org in one tab, the other still acts as the old one) and exposes the org context to the wire on every request. The right pattern is to set the active organization on the session via authClient.organization.setActive (or server-side auth.api.setActiveOrganization); Better Auth then attaches session.activeOrganizationId automatically.
Incorrect (org ID in every URL):
// pages routes scattered with orgId param
GET /api/orgs/:orgId/invoices
POST /api/orgs/:orgId/members
// server handler — every endpoint must re-verify membership
const member = await db.query.member.findFirst({
where: and(eq(member.userId, session.user.id), eq(member.organizationId, req.params.orgId)),
});
if (!member) throw new ForbiddenError();
// → duplicated check in every endpoint, drift on switchCorrect (active org on session):
// client: user clicks "Switch to Acme" in the org picker
await authClient.organization.setActive({ organizationId: "acme-123" });
// session is updated server-side; cookies and useSession both reflect the change// server endpoint — single source of truth
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function GET(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.session.activeOrganizationId) {
return new Response("No active organization", { status: 403 });
}
const orgId = session.session.activeOrganizationId; // typed, server-trusted
return Response.json(await db.query.invoices.findMany({ where: eq(invoices.organizationId, orgId) }));
}Common use cases:
- Pair with
customSessionto also project the active org's role/permissions onto the session response in a single round trip. - Use a database hook on session
updateto broadcast org-switch events to other devices (WebSocket / SSE).
Warning: setActiveOrganization invalidates the cookie cache for that session — adjacent tabs will see the change on next request, but websocket-driven UI may need to refetch session manually.
Reference: Better Auth — Organization Plugin
Pair Every Server Plugin With Its Client Counterpart
Most Better Auth plugins ship as two pieces: a server plugin (better-auth/plugins) that defines endpoints and a client plugin (better-auth/client/plugins) that adds method bindings (authClient.twoFactor.enable, authClient.organization.create, authClient.magicLink.signIn). Adding only the server side leaves the API surface accessible via raw fetch, but the typed methods are missing — every component that tries authClient.magicLink.signIn({ email }) gets a TypeError at runtime and a missing-property error at compile time.
Incorrect (server plugin enabled, no client plugin):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { magicLink, twoFactor, organization } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [magicLink({ sendMagicLink }), twoFactor(), organization({ ac, roles })],
});// lib/auth-client.ts — client plugins missing
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient(); // ← no plugins → no .magicLink, .twoFactor, .organizationawait authClient.signIn.magicLink({ email }); // ← TypeError: signIn.magicLink is not a functionCorrect (every server plugin paired):
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import {
magicLinkClient,
twoFactorClient,
organizationClient,
inferAdditionalFields,
} from "better-auth/client/plugins";
import { ac, owner, admin, member } from "./permissions";
import type { auth } from "./auth";
export const authClient = createAuthClient({
plugins: [
inferAdditionalFields<typeof auth>(),
magicLinkClient(),
twoFactorClient({
onTwoFactorRedirect() { window.location.href = "/two-factor"; },
}),
organizationClient({ ac, roles: { owner, admin, member } }),
],
});Implementation (pairing table — keep this in sync with `auth.ts`):
| Server plugin | Client plugin |
|---|---|
magicLink | magicLinkClient |
twoFactor | twoFactorClient |
organization | organizationClient |
admin | adminClient |
username | usernameClient |
passkey | passkeyClient |
emailOTP | emailOTPClient |
anonymous | anonymousClient |
multiSession | multiSessionClient |
jwt | (no client plugin — JWT consumers use the JWKS endpoint) |
nextCookies | (no client plugin — server-only) |
Warning: The reverse asymmetry is also a bug — a client plugin without its server counterpart will produce 404s when the client calls its endpoints.
Reference: Better Auth — Plugins
Define Access Control and Roles Once, Share Between Server and Client Plugins
The organization and admin plugins each take an access controller (ac) and a set of named roles (owner, admin, member, etc.). The server uses these to enforce permission checks on every API call; the client uses the same definitions to gate UI ("if user has member:read, show this button"). Defining them twice — once on the server, once on the client — guarantees drift: roles get renamed in one place, permissions added in the other, and the UI shows actions the server then rejects. Always define ac + roles in a single shared module and import into both sides.
Incorrect (separate role definitions diverge over time):
// lib/auth.ts (server)
import { organization } from "better-auth/plugins";
import { createAccessControl } from "better-auth/plugins/access";
const ac = createAccessControl({
invoices: ["read", "write"],
members: ["read", "invite", "remove"],
});
const admin = ac.newRole({ invoices: ["read", "write"], members: ["read", "invite"] });
const member = ac.newRole({ invoices: ["read"] });
export const auth = betterAuth({
plugins: [organization({ ac, roles: { admin, member } })],
});// lib/auth-client.ts (client) — duplicated and already out of sync
import { organizationClient } from "better-auth/client/plugins";
import { createAccessControl } from "better-auth/plugins/access";
const ac = createAccessControl({
invoices: ["read", "write"],
members: ["read", "invite"], // ← missing "remove", added in server later
});
// ...Correct (single source of truth):
// lib/permissions.ts — imported by BOTH server auth and client auth
import { createAccessControl } from "better-auth/plugins/access";
export const ac = createAccessControl({
invoices: ["read", "write", "delete"],
members: ["read", "invite", "remove"],
});
export const owner = ac.newRole({
invoices: ["read", "write", "delete"],
members: ["read", "invite", "remove"],
});
export const admin = ac.newRole({
invoices: ["read", "write"],
members: ["read", "invite"],
});
export const member = ac.newRole({
invoices: ["read"],
});// lib/auth.ts (server)
import { organization } from "better-auth/plugins";
import { ac, owner, admin, member } from "./permissions";
export const auth = betterAuth({
plugins: [organization({ ac, roles: { owner, admin, member } })],
});// lib/auth-client.ts (client)
import { organizationClient } from "better-auth/client/plugins";
import { ac, owner, admin, member } from "./permissions";
export const authClient = createAuthClient({
plugins: [organizationClient({ ac, roles: { owner, admin, member } })],
});Reference: Better Auth — Organization Plugin: Access Control
Set appName as the 2FA Issuer for Authenticator App Display
When the twoFactor plugin enrolls a user, it embeds an issuer field in the otpauth:// URI scanned by Google Authenticator, 1Password, Authy, etc. That string is what users see next to the rotating code: "Better Auth (user@example.com)" by default — confusing for end users who don't know what Better Auth is, and a support headache when they have multiple TOTP entries from different apps. Better Auth uses your top-level appName as the issuer, so setting appName correctly is the entire fix.
Incorrect (no appName, generic display):
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [twoFactor()], // issuer falls back to "Better Auth"
});Correct (appName flows through as TOTP issuer):
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
appName: "Example", // ← shown as "Example (user@example.com)" in authenticator apps
plugins: [
twoFactor({
// Optional: customize OTP length, expiry, backup codes
otpOptions: { period: 30, digits: 6 },
backupCodes: { length: 10, amount: 10 },
}),
],
});// Client side — pair with the matching client plugin
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = "/two-factor";
},
}),
],
});Warning: Don't rename appName after launch — users with existing TOTP enrollments will see two entries (old + new) in their authenticator app and the new entry won't match secrets they've already saved. If a rename is unavoidable, force re-enrollment via a migration.
Reference: Better Auth — Two Factor Plugin
Mount the Catch-All Handler at /api/auth/[...all] (or Framework Equivalent)
Better Auth exposes its entire API surface — sign-in, sign-up, OAuth callbacks, session, verification — under a single base path that defaults to /api/auth. Your framework's route file must catch every sub-path beneath it and forward to auth.handler(req) (or the framework-specific helper). Mounting it at /api/auth only (no catch-all) returns 404 for /api/auth/sign-in/social; using a different prefix means OAuth callback URLs registered with Google/GitHub never resolve.
Incorrect (Next.js App Router with non-catchall route):
app/
└── api/
└── auth/
└── route.ts ← only matches /api/auth, not /api/auth/sign-in, /api/auth/callback/google, ...Correct (Next.js App Router catch-all):
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);Correct (Next.js Pages Router):
// pages/api/auth/[...all].ts
import { auth } from "@/lib/auth";
import { toNodeHandler } from "better-auth/node";
export default toNodeHandler(auth);
// Disable Next's body parsing — Better Auth reads the raw request
export const config = { api: { bodyParser: false } };Correct (SvelteKit):
// src/routes/api/auth/[...all]/+server.ts
import { auth } from "$lib/auth";
import { svelteKitHandler } from "better-auth/svelte-kit";
import { building } from "$app/environment";
export async function handle({ event, resolve }) {
return svelteKitHandler({ event, resolve, auth, building });
}Correct (Hono / Bun / Node native):
import { Hono } from "hono";
import { auth } from "./auth";
const app = new Hono();
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));// Express
import express from "express";
import { toNodeHandler } from "better-auth/node";
const app = express();
app.all("/api/auth/*", toNodeHandler(auth));
// IMPORTANT: mount BEFORE express.json() or auth handler can't read the bodyWarning (Express + body parsers): Mount auth BEFORE express.json() / express.urlencoded(). Once those parsers consume the request body, the auth handler reads an empty stream and POSTs return 400.
Reference: Better Auth — Integrations
Mount Auth Before Any Body-Parsing Middleware
auth.handler (and the framework-specific helpers like toNodeHandler, toNextJsHandler) read the raw POST body to parse credentials, OAuth state, and verification tokens. Express's express.json(), custom logging middleware that calls req.body, and Next.js's default body parser all consume the stream. Once consumed, the auth handler reads an empty body and returns 400 — even though the route is mounted correctly.
Incorrect (Express with body parser before auth):
import express from "express";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth";
const app = express();
app.use(express.json()); // ← consumes the body first
app.all("/api/auth/*", toNodeHandler(auth)); // ← handler sees empty streamCorrect (auth handler BEFORE body parsers, or body parser scoped away from /api/auth):
import express from "express";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth";
const app = express();
// Option 1: mount auth FIRST
app.all("/api/auth/*", toNodeHandler(auth));
app.use(express.json()); // for the rest of your API
// Option 2: scope body parser AWAY from /api/auth/*
app.use((req, res, next) => {
if (req.path.startsWith("/api/auth")) return next();
return express.json()(req, res, next);
});
app.all("/api/auth/*", toNodeHandler(auth));Incorrect (Next.js Pages Router with default body parser enabled):
// pages/api/auth/[...all].ts
import { toNodeHandler } from "better-auth/node";
import { auth } from "@/lib/auth";
export default toNodeHandler(auth);
// Missing config — Next parses JSON by defaultCorrect (disable Next Pages body parsing for the auth route):
// pages/api/auth/[...all].ts
import { toNodeHandler } from "better-auth/node";
import { auth } from "@/lib/auth";
export default toNodeHandler(auth);
export const config = {
api: { bodyParser: false }, // ← Better Auth reads the raw stream
};Warning: The Next.js App Router (route.ts) does NOT pre-parse bodies — toNextJsHandler is safe by default. This issue only affects Pages Router and Node-style frameworks.
Use the Node.js Runtime for Middleware That Calls auth.api
Next.js middleware defaults to the Edge Runtime, which doesn't support most database drivers (pg, mysql2, mongodb, Prisma's binary engine). If your middleware calls auth.api.getSession({ headers }) — which queries the database — it crashes at build or first invocation. Two valid patterns: (1) opt the middleware into the Node.js runtime (Next 15.2+); or (2) use the Edge-safe cookie-only check via getSessionCookie() for routing, then do the full session lookup in the route handler.
Incorrect (database call in default Edge middleware):
// middleware.ts — runs on Edge by default
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export async function middleware(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
// ↑ crashes on Edge: pg / prisma can't run here
if (!session) return NextResponse.redirect(new URL("/sign-in", req.url));
return NextResponse.next();
}Correct (opt into Node.js runtime — Next 15.2+):
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export async function middleware(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.redirect(new URL("/sign-in", req.url));
return NextResponse.next();
}
export const config = {
runtime: "nodejs", // ← required for auth.api calls
matcher: ["/dashboard/:path*"],
};Alternative (Edge-safe cookie check + per-page server validation):
// middleware.ts — stays on Edge, only checks cookie presence
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(req: NextRequest) {
const sessionCookie = getSessionCookie(req);
if (!sessionCookie) return NextResponse.redirect(new URL("/sign-in", req.url));
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };// app/dashboard/page.tsx — does the real session validation
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function Dashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
return <h1>Welcome {session.user.name}</h1>;
}When to prefer which: Edge + cookie check is faster at the edge but a stale cookie evades the check until the server page revalidates. Node middleware does the real check but adds DB latency to every protected route.
Reference: Better Auth — Next.js: Middleware
Set minPasswordLength to At Least 10, Not the Default 8
Better Auth's default minPasswordLength is 8 — a value carried forward from the NIST 800-63B 2017 guidance that is now considered weak. Modern recommendation (NIST 800-63B-4 draft, OWASP ASVS) is at least 8 with strong rate-limiting, or 10+ for general accounts and 14+ for admin. Raising the minimum at signup costs nothing and immediately deflects the most common credential-spray attempts. Combine with rateLimit on the sign-in path — neither setting alone is sufficient.
Incorrect (default 8 with no enforcement of complexity):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
// minPasswordLength defaults to 8
},
});Correct (10 minimum, plus complementary rate-limiting):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 256, // allow passphrases; default 128 is okay too
},
rateLimit: {
enabled: true,
customRules: {
"/sign-in/email": { window: 60, max: 5 }, // 5 attempts/min/IP
},
},
});Implementation (UI-level password strength check on the client):
"use client";
import { zxcvbn } from "@zxcvbn-ts/core";
export function PasswordField({ value, onChange }) {
const score = zxcvbn(value).score; // 0..4
return (
<>
<input type="password" value={value} onChange={(e) => onChange(e.target.value)} />
<meter min={0} max={4} value={score} />
{value.length < 10 && <span>At least 10 characters</span>}
</>
);
}Warning: Don't combine length minimums with mandatory character-class rules ("must contain a symbol") — modern guidance is that such rules push users to predictable patterns (Password1!) and add no entropy. Length + a breach-list check (HIBP API) gives more security than complexity rules.
Reference: Better Auth — Options: emailAndPassword
Override the Password Hash Function When Migrating from bcrypt/argon2
Better Auth's default password hash is scrypt. NextAuth/Auth.js uses bcrypt, Clerk uses bcrypt, Auth0 uses bcrypt or scrypt-with-different-params. If you import legacy user rows with their existing hashes and don't tell Better Auth how to verify them, every migrated user fails to sign in and must reset their password — a destructive UX moment that bleeds users. Override emailAndPassword.password.{hash, verify} to use the legacy algorithm, or use a verify function that detects the format and dispatches.
Incorrect (migrate bcrypt hashes from Clerk, use Better Auth defaults):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: { enabled: true },
// Default scrypt verify against bcrypt hash → "invalid password" forever
});Correct (use bcrypt for hash AND verify):
import { betterAuth } from "better-auth";
import bcrypt from "bcryptjs";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => bcrypt.hash(password, 12),
verify: async ({ hash, password }) => bcrypt.compare(password, hash),
},
},
});Alternative (gradual migration — accept both formats, rehash on next sign-in):
import { betterAuth } from "better-auth";
import bcrypt from "bcryptjs";
// scrypt verify imported from a small helper around node:crypto.scrypt
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => {
// New passwords use modern argon2/scrypt
return scryptHash(password);
},
verify: async ({ hash, password }) => {
// Detect legacy bcrypt format
if (hash.startsWith("$2a$") || hash.startsWith("$2b$") || hash.startsWith("$2y$")) {
const ok = await bcrypt.compare(password, hash);
if (ok) {
// Rehash with modern algorithm on next sign-in via databaseHooks.account.update
}
return ok;
}
return scryptVerify(password, hash);
},
},
},
});Common use cases:
- Migrating from Auth.js → Better Auth: stay on bcrypt to preserve all sessions; rehash gradually.
- Migrating from Auth0 with custom_password_hash: use Auth0's documented algorithm parameters in the verify function.
Reference: Better Auth — Email/Password Custom Hashing
Enable rateLimit With Persistent Storage in Production
Better Auth's rateLimit defends sign-in, password-reset, and OTP endpoints against credential stuffing. The default storage: "memory" works for a single-node dev environment but breaks down in production: every serverless instance gets its own counter (attacker hops instances to multiply the budget), and counters reset on every cold start. Use storage: "secondary-storage" backed by Redis/Upstash so the limit is enforced globally and survives restarts. Pair with customRules to apply tighter limits to expensive endpoints.
Incorrect (memory storage on serverless):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
rateLimit: {
enabled: true,
window: 60,
max: 100,
// storage defaults to "memory" — per-instance, per-cold-start
},
});Correct (persistent storage + per-path rules):
import { betterAuth, SecondaryStorage } from "better-auth";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
const secondaryStorage: SecondaryStorage = {
async get(key) {
const v = await redis.get<string>(key);
return v ?? null;
},
async set(key, value, ttl) {
if (ttl) await redis.set(key, value, { ex: ttl });
else await redis.set(key, value);
},
async delete(key) {
await redis.del(key);
},
};
export const auth = betterAuth({
secondaryStorage,
rateLimit: {
enabled: true,
storage: "secondary-storage",
window: 60,
max: 100,
customRules: {
"/sign-in/email": { window: 60, max: 5 }, // 5 / minute / IP — anti-brute force
"/forget-password": { window: 300, max: 3 }, // 3 / 5 min — anti-enumeration
"/two-factor/verify":{ window: 60, max: 10 },
},
},
});Common use cases:
- Use the same Redis/Upstash instance for
cookieCacheinvalidation, rate-limit, and any session-revocation broadcasts. - Whitelist known good IPs (your offices, monitoring systems) by returning early in a custom hook before the rate-limit middleware runs.
Warning: Better Auth keys rate-limit buckets by IP. Behind a CDN or proxy, ensure the real client IP is forwarded (X-Forwarded-For) and your framework respects it — otherwise every request looks like it came from the proxy, and one user can DoS everyone.
Reference: Better Auth — Rate Limit
Enable revokeSessionsOnPasswordReset to Invalidate All Active Sessions
The whole point of "reset my password" is "lock out whoever has my account." If Better Auth doesn't revoke existing sessions on reset, an attacker who already grabbed a session cookie can keep using it after the legitimate owner resets. The revokeSessionsOnPasswordReset option deletes every session row for the user atomically with the password update. This is opt-in (off by default) because some teams want continuity for benign password rotations, but for any user-facing reset flow it should be on.
Incorrect (default — sessions survive password reset):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => { /* ... */ },
// revokeSessionsOnPasswordReset defaults to false
// → attacker keeps using stolen session after victim resets
},
});Correct (revoke on reset, optionally notify user):
import { betterAuth } from "better-auth";
import { sendEmail } from "@/lib/email";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({ to: user.email, subject: "Reset your password", text: `Reset: ${url}` });
},
revokeSessionsOnPasswordReset: true, // ← invalidate all sessions atomically
onPasswordReset: async ({ user }) => {
// Side-effect: notify the user a reset happened (anti-takeover signal)
await sendEmail({
to: user.email,
subject: "Your password was changed",
text: "If this wasn't you, contact support immediately.",
});
},
},
});Benefits:
- Stolen sessions are invalidated atomically — no race between the reset commit and the revoke step.
- The
onPasswordResetcallback gives you a single hook for security side-effects (audit log, anti-takeover email, MFA re-challenge). - Pairs cleanly with
cookieCache: { maxAge: 5 * 60 }— old cookie caches expire within minutes even on stale tabs.
When NOT to enable: Internal admin tools where forced periodic password rotation is mandated by policy and signing every user out on rotation creates a support flood.
Reference: Better Auth — Email/Password Options
Never Wildcard trustedOrigins; List Origins Explicitly
It's tempting under deadline pressure to set trustedOrigins: ["*"] to make CSRF errors go away — every guide warns against it, but the pattern persists because narrow-scope alternatives (per-environment lists, wildcard subdomains) are less obvious. A "*" literal bypasses Better Auth's CSRF defense entirely: any malicious page on any origin can submit a form against your auth API while a user is logged in. Use wildcard subdomain syntax (https://*.example.com) when you need flexibility, and review the list per release.
Incorrect (wildcard origin):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
trustedOrigins: ["*"], // ← any site can CSRF-submit against this auth API
});Incorrect (user-controlled domains):
// Don't pull origins from a database row a user can edit
const tenantOrigin = await db.query.tenant.findFirst({ where: eq(tenant.id, tenantId) });
export const auth = betterAuth({
trustedOrigins: [tenantOrigin.url], // ← attacker-controlled if they own a tenant
});Correct (explicit list, with subdomain wildcards for known-safe ranges):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
trustedOrigins: [
"https://app.example.com",
"https://admin.example.com",
"https://*.preview.example.com", // limited subdomain wildcard for previews
...(process.env.NODE_ENV !== "production"
? ["http://localhost:3000", "http://localhost:5173"]
: []),
],
});Correct (dynamic — function-based check with allowlist):
export const auth = betterAuth({
trustedOrigins: (request) => {
const origin = request.headers.get("origin");
if (!origin) return [];
// Allow only origins that belong to verified tenants
if (ALLOWED_TENANT_ORIGINS.has(origin)) return [origin];
return [];
},
});Warning: The wildcard-subdomain syntax (https://*.example.com) is exact — it matches single-level subdomains only. It does NOT match https://example.com (the apex) or https://a.b.example.com (nested). Add both explicitly if needed.
Reference: Better Auth — Security & trustedOrigins
Set sameSite, secure, and partitioned for Cross-Site Auth Flows
Better Auth's default cookies use sameSite: "lax" — safe for same-site flows but rejected by browsers when the auth API is on a different site than the frontend (subdomain in third-party context, iframe, embedded WebView). The required combination for cross-site cookies is sameSite: "none" + secure: true + partitioned: true. Setting only sameSite: "none" without secure makes Chrome and Firefox drop the cookie without an error. Missing partitioned will break in browsers enforcing CHIPS.
Incorrect (third-party context with lax-default cookies):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
baseURL: "https://api.example.com",
// App embedded as iframe at https://partner.com/dashboard
// Default sameSite: "lax" — browser drops the auth cookie on the iframe
});Correct (cross-site cookie attributes):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
baseURL: "https://api.example.com",
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true, // required when sameSite: "none"
partitioned: true, // CHIPS — required for third-party cookies in Chrome
},
},
});Alternative (override only the session cookie):
export const auth = betterAuth({
advanced: {
cookies: {
sessionToken: {
attributes: {
sameSite: "none",
secure: true,
partitioned: true,
},
},
},
},
});Common use cases:
- Embedded widget on a customer's site, auth API on yours.
- Mobile app loading a WebView from a different origin.
- Multi-app SSO where each app is a different effective top-level domain.
Warning: secure: true requires HTTPS — these settings will not work over http://localhost for cross-site testing. Use mkcert or a tunnel (ngrok, Cloudflare Tunnel) with TLS.
Reference: Better Auth — Cookies
Enable cookieCache to Cut Session Database Lookups by 99%
Every server-side auth.api.getSession call defaults to a database query on the session table — once per request per authenticated route. With dozens of server components on a page, this becomes the dominant database workload. cookieCache stores a signed snapshot of the session in a short-lived cookie; subsequent reads validate the signature locally and skip the DB until the cache expires (recommended 5 minutes). The DB is still consulted for sign-out and for sessions older than maxAge.
Incorrect (no cookie cache, every request hits DB):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// No session.cookieCache — every getSession() is a SELECT on session table
});Correct (signed cookie cache, 5-minute window):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes in seconds
},
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
});Warning (revocation latency): When cookieCache is enabled, sign-out and admin-driven session revocation are still applied immediately on the server, but stale cookies on other tabs/devices can remain valid until the cache window expires. For security-critical apps where instant revocation matters more than DB load, lower maxAge to 30 seconds or leave the feature off.
When NOT to use cookieCache:
- You revoke sessions frequently in response to security events (account takeover defense, "sign out everywhere") and need <1s revocation propagation.
- You mutate session-attached state (e.g., active organization, role) and need clients to see the change immediately.
Reference: Better Auth — Cookie Cache
Enable crossSubDomainCookies for Multi-Subdomain Apps
When sign-in happens on auth.example.com but the dashboard lives on app.example.com, the default cookie scope (Domain=auth.example.com) prevents the dashboard from reading the session — the user appears unauthenticated after redirect. The fix is to scope the cookie to the parent domain (.example.com) via crossSubDomainCookies + add every subdomain to trustedOrigins. Setting only one or the other breaks the flow: scoping without trusting causes CSRF rejections; trusting without scoping leaves the cookie inaccessible.
Incorrect (multi-subdomain app with default cookie scope):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
baseURL: "https://auth.example.com",
// Default cookie domain = auth.example.com — invisible to app.example.com
});Correct (cookie scoped to parent domain + all origins trusted):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
baseURL: "https://auth.example.com",
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: ".example.com", // leading dot makes the cookie visible to all subdomains
},
},
trustedOrigins: [
"https://app.example.com",
"https://admin.example.com",
"https://billing.example.com",
],
});Warning (eTLD+1 only): The domain must be the registrable domain (example.com), not a public suffix (co.uk, vercel.app). Browsers reject cookies scoped to public suffixes. If you're on *.vercel.app, you cannot share cookies across vercel.app subdomains — you need a custom domain.
When NOT to enable:
- Single-domain apps. The default scope is more restrictive (safer) and shaves a few bytes off every request.
- When subdomains have different trust levels (e.g.,
user-content.example.comfor user-uploaded HTML) — leaking the session cookie there is a security hole.
Reference: Better Auth — Cross-Subdomain Cookies
Use customSession to Add Computed Fields to the Session Response
Every protected route typically needs more than just user.id from the session — current organization, active role, feature flags, subscription tier. The naive pattern is to call getSession() then issue another query for each derived field, creating an N+queries pattern on every authenticated request. customSession lets you augment the session response server-side at session-fetch time, computing those fields in a single round-trip that integrates with the cookie cache.
Incorrect (multi-query pattern on every page):
// app/dashboard/page.tsx
const session = await auth.api.getSession({ headers });
if (!session) redirect("/sign-in");
const member = await db.query.member.findFirst({ // ← extra query
where: and(eq(member.userId, session.user.id), eq(member.organizationId, activeOrg)),
});
const subscription = await stripe.subscriptions.retrieve(/* ... */); // ← extra round-tripCorrect (customSession plugin computes fields once per session):
// lib/auth.ts
import { betterAuth } from "better-auth";
import { customSession } from "better-auth/plugins";
import { db } from "@/db";
export const auth = betterAuth({
plugins: [
customSession(async ({ user, session }) => {
const member = await db.query.member.findFirst({
where: eq(member.userId, user.id),
with: { organization: true },
});
return {
user,
session,
activeOrganization: member?.organization,
role: member?.role ?? "guest",
};
}),
],
// Pair with cookieCache so the augmented payload is reused for 5 minutes
session: { cookieCache: { enabled: true, maxAge: 5 * 60 } },
});// app/dashboard/page.tsx — single call, all fields available
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
const { user, role, activeOrganization } = session; // typed correctly via inferAdditionalFieldsWarning (cache invalidation): Fields added by customSession are baked into the cookie cache for maxAge. If you mutate the underlying data (e.g., switch the active organization), you must rotate the session via auth.api.updateSession or set cookieCache.maxAge low enough to tolerate staleness.
Reference: Better Auth — customSession Plugin
Configure expiresIn and updateAge Together for Sliding-Window Sessions
Better Auth's session model is sliding-window: each session has a hard expiresIn lifetime, but updateAge controls how often the expiration is bumped forward on activity. Setting expiresIn without updateAge (or vice versa) gives you a non-sliding fixed-window session that boots active users back to sign-in mid-session. Setting updateAge to zero forces a DB write on every request. The canonical balance is ~7 day total lifetime with a daily slide.
Incorrect (no updateAge — fixed 7-day window):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
// updateAge missing — sessions never extend, active users logged out at day 7
},
});Incorrect (updateAge: 0 — DB write per request):
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 0, // ← session.expiresAt rewritten on every request → write amplification
}Correct (sliding window, ~daily extension):
import { betterAuth } from "better-auth";
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7, // hard cap: 7 days from last extension
updateAge: 60 * 60 * 24, // slide forward at most once per day of activity
},
});Benefits:
- Active users never get unexpectedly logged out — each day of use extends the window.
- Inactive sessions expire on schedule (7 days from last activity).
- Database write pressure stays bounded (one update per user per day, not per request).
When NOT to use sliding sessions:
- Compliance contexts (PCI, HIPAA, SOC2 access control) sometimes mandate fixed absolute lifetimes. In those cases set a low
expiresInand require re-authentication — don't try to slide.
Reference: Better Auth — Session Expiration
Use auth.api.getSession on the Server, authClient.useSession on the Client
Better Auth exposes two parallel session APIs and they're not interchangeable. auth.api.getSession({ headers }) reads the session cookie from the request headers and runs server-side — required in server components, server actions, route handlers, and middleware. authClient.useSession() (and authClient.getSession()) is a fetch-driven reactive hook that runs in the browser — required for any UI that updates when the user signs in or out. Mixing them produces null sessions in server components (because authClient has no request headers) or stale UI on the client (because auth.api doesn't react to sign-out events).
Incorrect (using authClient on the server):
// app/dashboard/page.tsx — Server Component
import { authClient } from "@/lib/auth-client";
export default async function Dashboard() {
const session = await authClient.getSession(); // ← no headers; returns null
if (!session.data) redirect("/sign-in");
return <h1>Hello {session.data.user.name}</h1>;
}Correct (server uses auth.api.getSession with headers):
// app/dashboard/page.tsx — Server Component
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function Dashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
return <h1>Hello {session.user.name}</h1>;
}Correct (client UI uses useSession for reactivity):
// components/UserMenu.tsx — Client Component
"use client";
import { authClient } from "@/lib/auth-client";
export function UserMenu() {
const { data: session, isPending } = authClient.useSession();
if (isPending) return <Skeleton />;
if (!session) return <SignInButton />;
return (
<button onClick={() => authClient.signOut()}>
Sign out {session.user.name}
</button>
);
}When to also use server-side in client paths: For the initial render of a client component on the server, pass the session as a prop from the server component above it — avoids the loading flicker useSession produces before the first fetch resolves.
Reference: Better Auth — Session Management
Related skills
FAQ
What does better-auth do?
better-auth is a Claude Code skill for security. It helps developers move faster with AI-assisted coding.
When should I use better-auth?
When you need to helps with security tasks during ai-assisted development, or when better-auth is a claude code skill for security. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
better-auth; Security; AI-coding skill.