| name | better-auth-best-practices |
| description | Better Auth framework reference — configuration, security, rate limiting, sessions, plugins, and production hardening. Use when configuring Better Auth, auditing auth security, adding plugins, or troubleshooting Heartwood. |
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, 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
npx @better-auth/cli@latest generate
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,
window: 10,
max: 100,
},
});
Storage Options
rateLimit: {
storage: "secondary-storage",
}
| 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,
max: 5,
},
"/api/auth/sign-up/email": {
window: 60,
max: 3,
},
"/api/auth/some-safe-endpoint": false,
},
}
Custom Storage
For non-standard backends:
rateLimit: {
customStorage: {
get: async (key) => {
},
set: async (key, 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,
updateAge: 60 * 60 * 24,
freshAge: 60 * 60 * 24,
}
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,
strategy: "compact",
version: 1,
},
}
| 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,
}
Trusted Origins
trustedOrigins: [
"https://app.grove.place",
"https://*.grove.place",
"exp://192.168.*.*:*/*",
]
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",
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,
disableIpTracking: false,
},
trustedProxyHeaders: true,
}
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:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const auth = createAuth(env, ctx);
return auth.handler(request);
},
};
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",
}
State tokens: 32-character random strings, expire after 10 minutes, contain encrypted callback URLs + PKCE verifier.
Encrypt Stored OAuth Tokens
account: {
encryptOAuthTokens: true,
}
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,
}
Password Reset
emailAndPassword: {
sendResetPassword: async ({ user, url }) => {
await sendEmail({ to: user.email, subject: "Reset your password", url });
},
password: {
minLength: 8,
maxLength: 128,
},
revokeSessionsOnPasswordReset: true,
}
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";
plugins: [
twoFactor({
issuer: "Grove",
totpOptions: { digits: 6, period: 30 },
backupCodeOptions: { amount: 10, length: 10, storeBackupCodes: "encrypted" },
}),
]
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,
})
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,
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({
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
type Session = typeof auth.$Infer.Session;
type User = typeof auth.$Infer.Session.user;
createAuthClient<typeof auth>();
Hooks
Endpoint Hooks
hooks: {
before: [
{
matcher: (ctx) => ctx.path === "/sign-in/email",
handler: createAuthMiddleware(async (ctx) => {
}),
},
],
after: [
{
matcher: (ctx) => true,
handler: createAuthMiddleware(async (ctx) => {
}),
},
],
}
Database Hooks
databaseHooks: {
user: {
create: {
before: async ({ data }) => { },
after: async ({ data }) => { },
},
},
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
Complete Production Config Example
import { betterAuth } from "better-auth";
import { twoFactor, organization } from "better-auth/plugins";
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),
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 },
},
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 60 * 60,
cookieCache: {
enabled: true,
maxAge: 300,
strategy: "jwe",
},
},
account: {
encryptOAuthTokens: true,
storeStateStrategy: "cookie",
},
advanced: {
useSecureCookies: true,
crossSubDomainCookies: {
enabled: true,
domain: ".grove.place",
},
ipAddress: {
ipAddressHeaders: ["x-forwarded-for"],
ipv6Subnet: 64,
},
backgroundTasks: {
handler: (promise) => ctx.waitUntil(promise),
},
},
plugins: [
twoFactor({
issuer: "Grove",
backupCodeOptions: { storeBackupCodes: "encrypted" },
}),
organization(),
],
databaseHooks: {
session: {
create: {
after: async ({ data, ctx: hookCtx }) => {
console.log(`[audit] session created: user=${data.userId}`);
},
},
},
},
});
}
Resources