| 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