- 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](https://better-auth.com/docs)
**Source:** Synthesized from [better-auth/skills](https://github.com/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
```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 |
|--------|-------|
| `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
```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 |
|---------|----------|
| `"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:
```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
1. If `secondaryStorage` defined → sessions go there (not DB)
2. Set `session.storeSessionInDatabase: true` to also persist to DB
3. No database + `cookieCache` → 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)
}
```
**`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:
```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 |
|----------|-------------|
| `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:
1. `options.secret` in config
2. `BETTER_AUTH_SECRET` env var
3. `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:
1. **Origin header validation** — `Origin`/`Referer` must match trusted origins
2. **Fetch metadata** — Uses `Sec-Fetch-Site`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest` headers
3. **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: `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
```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 `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:
```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 `waitUntil` export from `@vercel/functions`:
```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:
1. **Consistent response messages** — Password reset always returns generic message
2. **Dummy operations** — When user isn't found, still performs token generation + DB lookups
3. **Background email sending** — Async to prevent timing differences
---
## OAuth / Social Provider Security
### PKCE (Automatic)
Better Auth automatically uses PKCE for all OAuth flows:
1. Generates 128-character random `code_verifier`
2. Creates `code_challenge` using S256 (SHA-256)
3. 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
Ver no GitHub