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 , )
- 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: (Grove-specific integration),
(auth architecture),
(deep security)
Grove Context: Heartwood
Heartwood is Grove's auth service, powered by Better Auth on Cloudflare Workers.
| Component | Detail |
|---|
| Frontend | |
| API | |
| Database | Cloudflare D1 (SQLite) |
| Session cache | Cloudflare KV () |
| Providers | Google OAuth, Magic Links, Passkeys |
| Cookie domain | (cross-subdomain SSO) |
Everything in this skill applies directly to Heartwood. The
skill covers Grove-specific integration patterns (client setup, route protection, error codes). This skill covers the framework itself.
Quick Reference
Environment Variables
| Variable | Purpose |
|---|
| Encryption secret (min 32 chars). Generate: |
| Base URL (e.g., https://auth-api.grove.place
) |
BETTER_AUTH_TRUSTED_ORIGINS
| Comma-separated trusted origins |
Only define
/
in config if env vars are NOT set.
File Location
CLI looks for
in:
,
,
, or under
. Use
for custom path.
CLI Commands
bash
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 |
|---|
| Display name (used in 2FA issuer, emails) |
| Only if not set |
| Default . Set for root |
| Only if not set |
| Required. Connection or adapter instance |
| Redis/KV for sessions & rate limits |
| to activate |
| { google: { clientId, clientSecret }, ... }
|
| Array of plugins |
| CSRF whitelist (baseURL auto-trusted) |
Database
Direct connections: Pass
,
pool,
, or
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
mapping to table
, use
(Prisma reference), not
.
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
typescript
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
typescript
rateLimit: {
storage: "secondary-storage", // Best for production
}
| Storage | Behavior |
|---|
| Fast, resets on restart. Not recommended for serverless. |
| Persistent, adds DB load |
| Uses configured KV/Redis. Default when available. |
For Heartwood: Use
backed by Cloudflare KV.
Per-Endpoint Rules
Better Auth applies stricter defaults to sensitive endpoints:
- , , , : 3 requests per 10 seconds
Override for specific paths:
typescript
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:
typescript
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 defined → sessions go there (not DB)
- Set
session.storeSessionInDatabase: true
to also persist to DB
- No database + → fully stateless mode
Key Options
typescript
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)
}
— 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:
typescript
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
strategy: "compact", // Options: "compact", "jwt", "jwe"
version: 1, // Change to invalidate all sessions
},
}
| Strategy | Description |
|---|
| Base64url + HMAC. Smallest size. Default. |
| Standard HS256 JWT. Readable but signed. |
| 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:
- in config
- env var
- 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 — / must match trusted origins
- Fetch metadata — Uses , , headers
- First-login protection — Validates origin even without cookies
typescript
advanced: {
disableCSRFCheck: false, // KEEP THIS FALSE
}
Trusted Origins
typescript
trustedOrigins: [
"https://app.grove.place",
"https://*.grove.place", // Wildcard subdomain
"exp://192.168.*.*:*/*", // Custom schemes (Expo)
]
Dynamic computation:
typescript
trustedOrigins: async (request) => {
const tenant = getTenantFromRequest(request);
return [`https://${tenant}.grove.place`];
}
Validated parameters:
,
,
,
,
, and more. Invalid URLs get 403.
Cookie Security
Defaults are secure:
- when baseURL uses HTTPS or in production
- (CSRF prevention while allowing navigation)
- (no JavaScript access)
- prefix when secure is enabled
typescript
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
typescript
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
callback receives a promise that must outlive the response — on serverless platforms, you need the platform's
to keep it alive.
Cloudflare Workers: Capture
from the fetch handler and close over it:
typescript
// 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
export from
:
typescript
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
- Creates using S256 (SHA-256)
- Validates code exchange with original verifier
State Parameter
typescript
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
typescript
account: {
encryptOAuthTokens: true, // AES-256-GCM
}
Enable if you store OAuth tokens for API access on behalf of users.
Email & Password
Email Verification
typescript
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
typescript
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
and
functions.
Two-Factor Authentication
Setup
typescript
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
flag only activates after successful TOTP verification.
Sign-In Flow with 2FA
- User signs in with credentials
- Response includes
- 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:
typescript
twoFactor({
trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days
})
// During verification:
await authClient.twoFactor.verifyTotp({ code, trustDevice: true });
OTP (Email/SMS)
typescript
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:
typescript
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
- 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
typescript
import { twoFactor, organization } from "better-auth/plugins";
| Plugin | Purpose | Scoped Package? |
|---|
| TOTP/OTP/backup codes | No |
| Teams & multi-tenant | No |
| WebAuthn | |
| Passwordless email | No |
| Email-based OTP | No |
| Username auth | No |
| Phone auth | No |
| User management | No |
| API key auth | No |
| Bearer token auth | No |
| JWT tokens | No |
| Multiple sessions | No |
| SAML/OIDC enterprise | |
| Be an OAuth provider | No |
| Be an OIDC provider | No |
| API documentation | No |
| 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 | |
| Svelte/SvelteKit | |
| Vue/Nuxt | |
| Solid | |
| Vanilla JS | |
Key methods:
,
,
,
,
,
,
,
.
Type Safety
typescript
// 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
typescript
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
typescript
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 (): ,
,
,
/
,
,
,
,
,
.
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
Complete Production Config Example
typescript
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