Better Auth Best Practices
Comprehensive reference for the Better Auth framework. Covers configuration, security hardening, rate limiting, session management, plugins, and production deployment patterns.
Canonical docs: better-auth.com/docs Source: Synthesized from better-auth/skills (official upstream) + Grove production experience.
When to Activate
-
Configuring or modifying Better Auth server/client setup
-
Auditing auth security (pair with raccoon-audit , turtle-harden )
-
Adding or configuring rate limiting
-
Setting up session management, cookie caching, or secondary storage
-
Adding plugins (2FA, organizations, passkeys, etc.)
-
Troubleshooting auth issues on Heartwood or any Better Auth deployment
-
Reviewing security posture before production deploy
Pair with: heartwood-auth (Grove-specific integration), spider-weave (auth architecture), turtle-harden (deep security)
Grove Context: Heartwood
Heartwood is Grove's auth service, powered by Better Auth on Cloudflare Workers.
Component Detail
Frontend heartwood.grove.place
API auth-api.grove.place
Database Cloudflare D1 (SQLite)
Session cache Cloudflare KV (SESSION_KV )
Providers Google OAuth, Magic Links, Passkeys
Cookie domain .grove.place (cross-subdomain SSO)
Everything in this skill applies directly to Heartwood. The heartwood-auth skill covers Grove-specific integration patterns (client setup, route protection, error codes). This skill covers the framework itself.
Quick Reference
Environment Variables
Variable Purpose
BETTER_AUTH_SECRET
Encryption secret (min 32 chars). Generate: openssl rand -base64 32
BETTER_AUTH_URL
Base URL (e.g., https://auth-api.grove.place )
BETTER_AUTH_TRUSTED_ORIGINS
Comma-separated trusted origins
Only define baseURL /secret in config if env vars are NOT set.
File Location
CLI looks for auth.ts in: ./ , ./lib , ./utils , or under ./src . Use --config for custom path.
CLI Commands
npx @better-auth/cli@latest migrate # Apply schema (built-in adapter) npx @better-auth/cli@latest generate # Generate schema for Prisma/Drizzle
Re-run after adding/changing plugins.
Core Configuration
Option Notes
appName
Display name (used in 2FA issuer, emails)
baseURL
Only if BETTER_AUTH_URL not set
basePath
Default /api/auth . Set / for root
secret
Only if BETTER_AUTH_SECRET not set
database
Required. Connection or adapter instance
secondaryStorage
Redis/KV for sessions & rate limits
emailAndPassword
{ enabled: true } to activate
socialProviders
{ google: { clientId, clientSecret }, ... }
plugins
Array of plugins
trustedOrigins
CSRF whitelist (baseURL auto-trusted)
Database
Direct connections: Pass pg.Pool , mysql2 pool, better-sqlite3 , or bun:sqlite instance.
ORM adapters: Import from better-auth/adapters/drizzle , better-auth/adapters/prisma , better-auth/adapters/mongodb .
Critical gotcha: Better Auth uses adapter model names, NOT underlying table names. If Prisma model is User mapping to table users , use modelName: "user" (Prisma reference), not "users" .
Rate Limiting
Better Auth has built-in rate limiting — enabled by default in production, disabled in development.
Why This Matters for Grove
Better Auth's rate limiter can replace custom threshold SDKs for auth endpoints. It's battle-tested, configurable per-endpoint, and integrates directly with the auth layer where it matters most.
Default Configuration
import { betterAuth } from "better-auth";
export const auth = betterAuth({ rateLimit: { enabled: true, // Default: true in production window: 10, // Time window in seconds (default: 10) max: 100, // Max requests per window (default: 100) }, });
Storage Options
rateLimit: { storage: "secondary-storage", // Best for production }
Storage Behavior
"memory"
Fast, resets on restart. Not recommended for serverless.
"database"
Persistent, adds DB load
"secondary-storage"
Uses configured KV/Redis. Default when available.
For Heartwood: Use "secondary-storage" backed by Cloudflare KV.
Per-Endpoint Rules
Better Auth applies stricter defaults to sensitive endpoints:
- /sign-in , /sign-up , /change-password , /change-email : 3 requests per 10 seconds
Override for specific paths:
rateLimit: { customRules: { "/api/auth/sign-in/email": { window: 60, // 1 minute max: 5, // 5 attempts }, "/api/auth/sign-up/email": { window: 60, max: 3, // Very strict for registration }, "/api/auth/some-safe-endpoint": false, // Disable rate limiting }, }
Custom Storage
For non-standard backends:
rateLimit: { customStorage: { get: async (key) => { // Return { count: number, expiresAt: number } or null }, set: async (key, data) => { // Store the rate limit data }, }, }
Each plugin can optionally define its own rate-limit rules per endpoint.
Session Management
Storage Priority
-
If secondaryStorage defined → sessions go there (not DB)
-
Set session.storeSessionInDatabase: true to also persist to DB
-
No database + cookieCache → fully stateless mode
Key Options
session: { expiresIn: 60 * 60 * 24 * 7, // 7 days (default) updateAge: 60 * 60 * 24, // Refresh every 24 hours (default) freshAge: 60 * 60 * 24, // 24 hours for sensitive actions (default) }
freshAge — defines how recently a user must have authenticated to perform sensitive operations. Use to require re-auth for password changes, viewing sensitive data, etc.
Cookie Cache Strategies
Cache session data in cookies to reduce DB/KV queries:
session: { cookieCache: { enabled: true, maxAge: 60 * 5, // 5 minutes strategy: "compact", // Options: "compact", "jwt", "jwe" version: 1, // Change to invalidate all sessions }, }
Strategy Description
compact
Base64url + HMAC. Smallest size. Default.
jwt
Standard HS256 JWT. Readable but signed.
jwe
A256CBC-HS512 encrypted. Maximum security.
Gotcha: Custom session fields are NOT cached — they're always re-fetched from storage.
Security Configuration
Secret Management
Better Auth looks for secrets in order:
-
options.secret in config
-
BETTER_AUTH_SECRET env var
-
AUTH_SECRET env var
Requirements:
-
Rejects default/placeholder secrets in production
-
Warns if shorter than 32 characters
-
Warns if entropy below 120 bits
CSRF Protection
Multi-layered by default:
-
Origin header validation — Origin /Referer must match trusted origins
-
Fetch metadata — Uses Sec-Fetch-Site , Sec-Fetch-Mode , Sec-Fetch-Dest headers
-
First-login protection — Validates origin even without cookies
advanced: { disableCSRFCheck: false, // KEEP THIS FALSE }
Trusted Origins
trustedOrigins: [ "https://app.grove.place", "https://.grove.place", // Wildcard subdomain "exp://192.168..:/*", // Custom schemes (Expo) ]
Dynamic computation:
trustedOrigins: async (request) => {
const tenant = getTenantFromRequest(request);
return [https://${tenant}.grove.place];
}
Validated parameters: callbackURL , redirectTo , errorCallbackURL , newUserCallbackURL , origin , and more. Invalid URLs get 403.
Cookie Security
Defaults are secure:
-
secure: true when baseURL uses HTTPS or in production
-
sameSite: "lax" (CSRF prevention while allowing navigation)
-
httpOnly: true (no JavaScript access)
-
__Secure- prefix when secure is enabled
advanced: { useSecureCookies: true, cookiePrefix: "better-auth", defaultCookieAttributes: { sameSite: "lax", }, crossSubDomainCookies: { enabled: true, domain: ".grove.place", // Note the leading dot additionalCookies: ["session_token", "session_data"], }, }
Warning: Cross-subdomain cookies expand attack surface. Only enable if you trust all subdomains.
IP-Based Security
advanced: { ipAddress: { ipAddressHeaders: ["x-forwarded-for", "x-real-ip"], ipv6Subnet: 64, // Group IPv6 by /64 disableIpTracking: false, // Keep enabled for rate limiting }, trustedProxyHeaders: true, // Only if behind a trusted proxy }
Background Tasks (Timing Attack Prevention)
Sensitive operations should complete in constant time. The handler callback receives a promise that must outlive the response — on serverless platforms, you need the platform's waitUntil to keep it alive.
Cloudflare Workers: Capture ExecutionContext from the fetch handler and close over it:
// In your Worker fetch handler: export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { // Create auth with ctx in scope const auth = createAuth(env, ctx); return auth.handler(request); }, };
// In your auth config factory: function createAuth(env: Env, ctx: ExecutionContext) { return betterAuth({ // ... advanced: { backgroundTasks: { handler: (promise) => ctx.waitUntil(promise), }, }, }); }
Vercel/Next.js: Use the waitUntil export from @vercel/functions :
import { waitUntil } from "@vercel/functions";
advanced: { backgroundTasks: { handler: (promise) => waitUntil(promise), }, }
Ensures email sending doesn't leak information about whether a user exists.
Account Enumeration Prevention
Built-in protections:
-
Consistent response messages — Password reset always returns generic message
-
Dummy operations — When user isn't found, still performs token generation + DB lookups
-
Background email sending — Async to prevent timing differences
OAuth / Social Provider Security
PKCE (Automatic)
Better Auth automatically uses PKCE for all OAuth flows:
-
Generates 128-character random code_verifier
-
Creates code_challenge using S256 (SHA-256)
-
Validates code exchange with original verifier
State Parameter
account: { storeStateStrategy: "cookie", // "cookie" (default) or "database" }
State tokens: 32-character random strings, expire after 10 minutes, contain encrypted callback URLs + PKCE verifier.
Encrypt Stored OAuth Tokens
account: { encryptOAuthTokens: true, // AES-256-GCM }
Enable if you store OAuth tokens for API access on behalf of users.
Email & Password
Email Verification
emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Verify your email", url }); }, sendOnSignUp: true, requireEmailVerification: true, // Blocks sign-in until verified }
Password Reset
emailAndPassword: { sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Reset your password", url }); }, password: { minLength: 8, // Default maxLength: 128, // Default }, revokeSessionsOnPasswordReset: true, // Log out all sessions }
Security: Reset tokens are 24-character alphanumeric strings, expire after 1 hour, single-use.
Password Hashing
Default: scrypt. For Argon2id, provide custom hash and verify functions.
Two-Factor Authentication
Setup
import { twoFactor } from "better-auth/plugins";
// Server plugins: [ twoFactor({ issuer: "Grove", // Shown in authenticator apps totpOptions: { digits: 6, period: 30 }, backupCodeOptions: { amount: 10, length: 10, storeBackupCodes: "encrypted" }, }), ]
// Client plugins: [ twoFactorClient({ onTwoFactorRedirect() { window.location.href = "/2fa"; }, }), ]
Run migrations after adding. The twoFactorEnabled flag only activates after successful TOTP verification.
Sign-In Flow with 2FA
-
User signs in with credentials
-
Response includes twoFactorRedirect: true
-
Session cookie removed temporarily
-
Two-factor cookie set (10-minute expiration)
-
User verifies via TOTP/OTP/backup code
-
Session cookie restored
Trusted Devices
Skip 2FA on subsequent sign-ins:
twoFactor({ trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days })
// During verification: await authClient.twoFactor.verifyTotp({ code, trustDevice: true });
OTP (Email/SMS)
twoFactor({
otpOptions: {
sendOTP: async ({ user, otp }) => {
await sendEmail({ to: user.email, subject: "Your code", text: Code: ${otp} });
},
period: 5, // Minutes
digits: 6,
allowedAttempts: 5,
storeOTP: "encrypted",
},
})
Built-in 2FA Protections
-
Rate limiting: 3 requests per 10 seconds on 2FA endpoints
-
OTP attempt limiting: configurable max attempts
-
Constant-time comparison prevents timing attacks
-
TOTP secrets encrypted with symmetric encryption
-
Backup codes encrypted by default
-
Limitation: 2FA requires credential accounts — social-only accounts can't enable it
Organizations Plugin
Multi-tenant organization support:
import { organization } from "better-auth/plugins";
plugins: [ organization({ // Limit who can create orgs allowUserToCreateOrganization: async (user) => { return user.emailVerified; }, }), ]
Key Concepts
-
Active organization stored in session — scopes API calls after setActive()
-
Default roles: owner (full), admin (management), member (basic)
-
Dynamic access control for custom runtime permissions
-
Teams group members within organizations
-
Invitations expire after 48 hours (configurable), email-specific
-
Safety: Last owner cannot be removed or leave
Plugins Reference
import { twoFactor, organization } from "better-auth/plugins";
Plugin Purpose Scoped Package?
twoFactor
TOTP/OTP/backup codes No
organization
Teams & multi-tenant No
passkey
WebAuthn @better-auth/passkey
magicLink
Passwordless email No
emailOtp
Email-based OTP No
username
Username auth No
phoneNumber
Phone auth No
admin
User management No
apiKey
API key auth No
bearer
Bearer token auth No
jwt
JWT tokens No
multiSession
Multiple sessions No
sso
SAML/OIDC enterprise @better-auth/sso
oauthProvider
Be an OAuth provider No
oidcProvider
Be an OIDC provider No
openAPI
API documentation No
genericOAuth
Custom OAuth provider No
Client plugins go in createAuthClient({ plugins: [...] }) .
Always run migrations after adding plugins.
Client Setup
Import by framework:
Framework Import
React/Next.js better-auth/react
Svelte/SvelteKit better-auth/svelte
Vue/Nuxt better-auth/vue
Solid better-auth/solid
Vanilla JS better-auth/client
Key methods: signUp.email() , signIn.email() , signIn.social() , signOut() , useSession() , getSession() , revokeSession() , revokeSessions() .
Type Safety
// Infer types from server config type Session = typeof auth.$Infer.Session; type User = typeof auth.$Infer.Session.user;
// For separate client/server projects createAuthClient<typeof auth>();
Hooks
Endpoint Hooks
hooks: { before: [ { matcher: (ctx) => ctx.path === "/sign-in/email", handler: createAuthMiddleware(async (ctx) => { // Access: ctx.path, ctx.context.session, ctx.context.secret // Return modified context or void }), }, ], after: [ { matcher: (ctx) => true, handler: createAuthMiddleware(async (ctx) => { // Access: ctx.context.returned (response data) }), }, ], }
Database Hooks
databaseHooks: { user: { create: { before: async ({ data }) => { /* add defaults, return false to block / }, after: async ({ data }) => { / audit log, send welcome email */ }, }, }, session: { create: { after: async ({ data, ctx }) => { await auditLog("session.created", { userId: data.userId, ip: ctx?.request?.headers.get("x-forwarded-for"), }); }, }, }, }
Hook context (ctx.context ): session , secret , authCookies , password.hash() /verify() , adapter , internalAdapter , generateId() , tables , baseURL .
Common Gotchas
-
Model vs table name — Config uses ORM model name, not DB table name
-
Plugin schema — Re-run CLI after adding plugins (always!)
-
Secondary storage — Sessions go there by default, not DB
-
Cookie cache — Custom session fields NOT cached, always re-fetched
-
Stateless mode — No DB = session in cookie only, logout on cache expiry
-
Change email flow — Sends to current email first, then new email
-
2FA + social — 2FA only works on credential accounts, not social-only
-
Last owner — Cannot be removed from or leave an organization
-
Rate limit memory — Memory storage resets on restart, bad for serverless
Production Security Checklist
-
BETTER_AUTH_SECRET set (32+ chars, high entropy)
-
BETTER_AUTH_URL uses HTTPS
-
trustedOrigins configured for all valid origins
-
Rate limiting enabled with appropriate per-endpoint limits
-
Rate limit storage set to "secondary-storage" or "database" (not memory)
-
CSRF protection enabled (disableCSRFCheck: false )
-
Secure cookies enabled (automatic with HTTPS)
-
account.encryptOAuthTokens: true if storing tokens
-
Background tasks configured for serverless (capture ExecutionContext from fetch handler)
-
Audit logging via databaseHooks or hooks
-
IP tracking headers configured if behind proxy
-
Email verification enabled
-
Password reset implemented
-
2FA available for sensitive apps
-
Session expiry and refresh intervals reviewed
-
Cookie cache strategy chosen (jwe for sensitive session data)
-
account.accountLinking reviewed
Complete Production Config Example
import { betterAuth } from "better-auth"; import { twoFactor, organization } from "better-auth/plugins";
// Factory pattern — ctx comes from the Worker fetch handler export function createAuth(env: Env, ctx: ExecutionContext) { return betterAuth({ appName: "Grove", secret: env.BETTER_AUTH_SECRET, baseURL: "https://auth-api.grove.place", trustedOrigins: [ "https://heartwood.grove.place", "https://*.grove.place", ],
database: d1Adapter(env),
secondaryStorage: kvAdapter(env),
// Rate limiting (replaces custom threshold SDK for auth)
rateLimit: {
enabled: true,
storage: "secondary-storage",
customRules: {
"/api/auth/sign-in/email": { window: 60, max: 5 },
"/api/auth/sign-up/email": { window: 60, max: 3 },
"/api/auth/change-password": { window: 60, max: 3 },
},
},
// Sessions
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 24 hours
freshAge: 60 * 60, // 1 hour for sensitive actions
cookieCache: {
enabled: true,
maxAge: 300,
strategy: "jwe",
},
},
// OAuth
account: {
encryptOAuthTokens: true,
storeStateStrategy: "cookie",
},
// Security
advanced: {
useSecureCookies: true,
crossSubDomainCookies: {
enabled: true,
domain: ".grove.place",
},
ipAddress: {
ipAddressHeaders: ["x-forwarded-for"],
ipv6Subnet: 64,
},
backgroundTasks: {
handler: (promise) => ctx.waitUntil(promise), // ctx captured from fetch handler
},
},
// Plugins
plugins: [
twoFactor({
issuer: "Grove",
backupCodeOptions: { storeBackupCodes: "encrypted" },
}),
organization(),
],
// Audit hooks
databaseHooks: {
session: {
create: {
after: async ({ data, ctx: hookCtx }) => {
console.log(`[audit] session created: user=${data.userId}`);
},
},
},
},
}); }
Resources
-
Better Auth Docs
-
Options Reference
-
LLMs.txt
-
GitHub
-
Official Skills
-
Init Options Source