Awesome-omni-skill better-auth-best-practices
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.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/testing-security/better-auth-best-practices-majiayu000" ~/.claude/skills/diegosouzapw-awesome-omni-skill-better-auth-best-practices-1e8906 && rm -rf "$T"
skills/testing-security/better-auth-best-practices-majiayu000/SKILL.mdBetter 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 | |
| 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
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 |
|---|---|
| Encryption secret (min 32 chars). Generate: |
| Base URL (e.g., ) |
| 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 |
|---|---|
| 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 |
| |
| Array of plugins |
| 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 |
|---|---|
| Fast, resets on restart. Not recommended for serverless. |
| Persistent, adds DB load |
| 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
: 3 requests per 10 seconds/change-email
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
defined → sessions go there (not DB)secondaryStorage - Set
to also persist to DBsession.storeSessionInDatabase: true - No database +
→ fully stateless modecookieCache
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) }
— defines how recently a user must have authenticated to perform sensitive operations. Use to require re-auth for password changes, viewing sensitive data, etc.freshAge
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 |
|---|---|
| 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 configoptions.secret
env varBETTER_AUTH_SECRET
env varAUTH_SECRET
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
must match trusted originsReferer - Fetch metadata — Uses
,Sec-Fetch-Site
,Sec-Fetch-Mode
headersSec-Fetch-Dest - 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:
when baseURL uses HTTPS or in productionsecure: true
(CSRF prevention while allowing navigation)sameSite: "lax"
(no JavaScript access)httpOnly: true
prefix when secure is enabled__Secure-
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
using S256 (SHA-256)code_challenge - 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? |
|---|---|---|
| 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:
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
-
set (32+ chars, high entropy)BETTER_AUTH_SECRET -
uses HTTPSBETTER_AUTH_URL -
configured for all valid originstrustedOrigins - Rate limiting enabled with appropriate per-endpoint limits
- Rate limit storage set to
or"secondary-storage"
(not memory)"database" - CSRF protection enabled (
)disableCSRFCheck: false - Secure cookies enabled (automatic with HTTPS)
-
if storing tokensaccount.encryptOAuthTokens: true - Background tasks configured for serverless (capture
from fetch handler)ExecutionContext - Audit logging via
ordatabaseHookshooks - 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 (
for sensitive session data)jwe -
reviewedaccount.accountLinking
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}`); }, }, }, }, }); }