Skip to content
better-auth-best-practices logo

Better Auth Best Practices

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.

SKILL.md

Full skill instructions

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.

ComponentDetail
Frontendheartwood.grove.place
APIauth-api.grove.place
DatabaseCloudflare D1 (SQLite)
Session cacheCloudflare KV (SESSION_KV)
ProvidersGoogle 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

VariablePurpose
BETTER_AUTH_SECRETEncryption secret (min 32 chars). Generate: openssl rand -base64 32
BETTER_AUTH_URLBase URL (e.g., https://auth-api.grove.place)
BETTER_AUTH_TRUSTED_ORIGINSComma-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

OptionNotes
appNameDisplay name (used in 2FA issuer, emails)
baseURLOnly if BETTER_AUTH_URL not set
basePathDefault /​api/​auth. Set / for root
secretOnly if BETTER_AUTH_SECRET not set
databaseRequired. Connection or adapter instance
secondaryStorageRedis/​KV for sessions & rate limits
emailAndPassword{ enabled: true } to activate
socialProviders{ google: { clientId, clientSecret }, ... }
pluginsArray of plugins
trustedOriginsCSRF 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
}
StorageBehavior
"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

  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

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
  },
}
StrategyDescription
compactBase64url + HMAC. Smallest size. Default.
jwtStandard HS256 JWT. Readable but signed.
jweA256CBC-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
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: 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",  // 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:

  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

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

  1. User signs in with credentials
  2. Response includes twoFactorRedirect: true
  3. Session cookie removed temporarily
  4. Two-factor cookie set (10-minute expiration)
  5. User verifies via TOTP/​OTP/​backup code
  6. 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";
PluginPurposeScoped Package?
twoFactorTOTP/​OTP/​backup codesNo
organizationTeams & multi-tenantNo
passkeyWebAuthn@better-auth/​passkey
magicLinkPasswordless emailNo
emailOtpEmail-based OTPNo
usernameUsername authNo
phoneNumberPhone authNo
adminUser managementNo
apiKeyAPI key authNo
bearerBearer token authNo
jwtJWT tokensNo
multiSessionMultiple sessionsNo
ssoSAML/​OIDC enterprise@better-auth/​sso
oauthProviderBe an OAuth providerNo
oidcProviderBe an OIDC providerNo
openAPIAPI documentationNo
genericOAuthCustom OAuth providerNo

Client plugins go in createAuthClient({ plugins: [...] }).

Always run migrations after adding plugins.


Client Setup

Import by framework:

FrameworkImport
React/​Next.jsbetter-auth/​react
Svelte/​SvelteKitbetter-auth/​svelte
Vue/​Nuxtbetter-auth/​vue
Solidbetter-auth/​solid
Vanilla JSbetter-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

  1. Model vs table name — Config uses ORM model name, not DB table name
  2. Plugin schema — Re-run CLI after adding plugins (always!)
  3. Secondary storage — Sessions go there by default, not DB
  4. Cookie cache — Custom session fields NOT cached, always re-fetched
  5. Stateless mode — No DB = session in cookie only, logout on cache expiry
  6. Change email flow — Sends to current email first, then new email
  7. 2FA + social — 2FA only works on credential accounts, not social-only
  8. Last owner — Cannot be removed from or leave an organization
  9. Rate limit memory — Memory storage resets on restart, bad for serverless

Production Security Checklist

  • BETTER_AUTH_SECRET set (32+ chars, high entropy)
  • BETTER_AUTH_URL uses HTTPS
  • trustedOrigins configured 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: true if storing tokens
  • Background tasks configured for serverless (capture ExecutionContext from fetch handler)
  • Audit logging via databaseHooks or hooks
  • 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 (jwe for sensitive session data)
  • account.accountLinking reviewed

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}`);
          },
        },
      },
    },
  });
}

Resources

More skills from diegosouzapw

accessibility logo
diegosouzapw/awesome-omni-skill

accessibility

Accessibility guidelines for VS Code features — covers accessibility help dialogs, accessible views, verbosity settings, accessibility signals, ARIA alerts/status announcements, keyboard navigation, and ARIA labels/roles. Applies to both new interactive UI surfaces and updates to existing feature...

62 1
View
mermaid-diagrams logo
diegosouzapw/awesome-omni-skill

mermaid-diagrams

Mermaid diagram creation for flowcharts, sequences, ERDs, and more. Generate diagrams from text in markdown files. Use for documentation, architecture diagrams, and visual representations. Triggers on mermaid, flowchart, sequence diagram, ERD, entity relationship, gantt chart, pie chart, class di...

62 1
View
tauri2-react-rust logo
diegosouzapw/awesome-omni-skill

tauri2-react-rust

Guides development of cross-platform desktop apps with Tauri 2, TypeScript, React, and Rust. Use when building Tauri apps, implementing IPC, designing Rust backend or TypeScript/React frontend, when researching or cloning a website (open site, snapshot elements), when verifying local dev or built...

62 1
View
fp-ts-errors logo
diegosouzapw/awesome-omni-skill

fp-ts-errors

Handle errors as values using fp-ts Either and TaskEither for cleaner, more predictable TypeScript code. Use when implementing error handling patterns with fp-ts.

62 0
View
ring:pre-dev-research logo
diegosouzapw/awesome-omni-skill

ring:pre-dev-research

Gate 0 research phase for pre-dev workflow. Dispatches 3 parallel research agents to gather codebase patterns, external best practices, and framework documentation BEFORE creating PRD/TRD. Outputs research.md with file:line references.

62 0
View
create-assistant logo
diegosouzapw/awesome-omni-skill

create-assistant

Create and configure Vapi voice AI assistants with models, voices, transcribers, tools, hooks, and advanced settings. Use when building voice agents, phone bots, customer support assistants, or any conversational AI that handles phone or web calls.

62 0
View
codex-headless logo
diegosouzapw/awesome-omni-skill

codex-headless

Delegiere Aufgaben an OpenAI Codex CLI im Headless-Mode. Nutzt ChatGPT Subscription (KEIN API Key). Nur Workspace-Zugriff, KEIN System-Zugriff. Auto-Accept mit --full-auto. Web-Recherche mit --search.

62 0
View
social-media-scheduler logo
diegosouzapw/awesome-omni-skill

social-media-scheduler

Generate a full week of social media content for any topic. Outputs platform-optimized posts for Twitter/X, LinkedIn, and Instagram with hashtags and posting times.

62 0
View
Data Privacy Compliance logo
diegosouzapw/awesome-omni-skill

Data Privacy Compliance

Data privacy and regulatory compliance specialist for GDPR, CCPA, HIPAA, and international data protection laws. Use when implementing privacy controls, conducting data protection impact assessments, ensuring regulatory compliance, or managing data subject rights. Expert in consent management, da...

62 0
View
triple-review logo
diegosouzapw/awesome-omni-skill

triple-review

Reviews a task using the TRIPLE protocol, as the review agent.

62 0
View
Tech Stack & Architecture Decision logo
diegosouzapw/awesome-omni-skill

Tech Stack & Architecture Decision

Define and document the technology stack and architecture decisions for a project. Use when the user needs to choose a tech stack, make architecture decisions, define infrastructure choices, or document technology selections. Triggers on requests like "define the tech stack", "choose technologies...

62 0
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View