better-auth-best-practices
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
secondaryStoragedefined → sessions go there (not DB) - Set
session.storeSessionInDatabase: trueto 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.secretin configBETTER_AUTH_SECRETenv varAUTH_SECRETenv 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/Referermust match trusted origins - Fetch metadata — Uses
Sec-Fetch-Site,Sec-Fetch-Mode,Sec-Fetch-Destheaders - 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: truewhen baseURL uses HTTPS or in productionsameSite: "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_challengeusing 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_SECRETset (32+ chars, high entropy) -
BETTER_AUTH_URLuses HTTPS -
trustedOriginsconfigured 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: trueif storing tokens - Background tasks configured for serverless (capture
ExecutionContextfrom fetch handler) - Audit logging via
databaseHooksorhooks - 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 (
jwefor sensitive session data) -
account.accountLinkingreviewed
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}`);
},
},
},
},
});
}