name: better-auth-security-best-practices description: 'Better Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging. Use when reviewing auth security, brute-force protection, token handling, or deployment safety.' metadata: author: epicenter version: '1.0'
Reference Repositories
- Better Auth — TypeScript authentication framework with plugins
Upstream Grounding
When Better Auth rate limiting, CSRF and origin checks, cookie settings, secret handling, token encryption, audit behavior, or deployment security defaults affect correctness, ask DeepWiki a narrow question against better-auth/better-auth before relying on memory. Use it to orient, then verify decisive details against local installed types, source, or official docs before changing code.
Skip DeepWiki for stable security basics already documented below.
Secret Management
Configuring the Secret
import { betterAuth } from "better-auth";
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET, // or via `BETTER_AUTH_SECRET` env
});
Better Auth looks for secrets in this order:
options.secretin your configBETTER_AUTH_SECRETenvironment variableAUTH_SECRETenvironment variable
Secret Requirements
- Rejects default/placeholder secrets in production
- Warns if shorter than 32 characters or entropy below 120 bits
- Generate:
openssl rand -base64 32 - Never commit secrets to version control
Rate Limiting
Enabled in production by default. Applies to all endpoints. Plugins can override per-endpoint.
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
Options: "memory" (resets on restart, avoid on serverless), "database" (persistent), "secondary-storage" (Redis, default when available).
rateLimit: {
storage: "database",
}
Custom Storage
Implement your own rate limit storage:
rateLimit: {
customStorage: {
get: async (key) => {
// Return { count: number, expiresAt: number } or null
},
set: async (key, data) => {
// Store the rate limit data
},
},
}
Per-Endpoint Rules
Sensitive endpoints default to 3 requests per 10 seconds (/sign-in, /sign-up, /change-password, /change-email). Override:
rateLimit: {
customRules: {
"/api/auth/sign-in/email": {
window: 60, // 1 minute window
max: 5, // 5 attempts
},
"/api/auth/some-safe-endpoint": false, // Disable rate limiting
},
}
CSRF Protection
Multi-layer protection: origin header validation, Fetch Metadata checks, and first-login protection.
Configuration
import { betterAuth } from "better-auth";
export const auth = betterAuth({
advanced: {
disableCSRFCheck: false, // Default: false (keep enabled)
},
});
Only disable for testing or with an alternative CSRF mechanism.
Trusted Origins
Configuring Trusted Origins
import { betterAuth } from "better-auth";
export const auth = betterAuth({
baseURL: "https://api.example.com",
trustedOrigins: [
"https://app.example.com",
"https://admin.example.com",
],
});
The baseURL origin is automatically trusted. Also configurable via env: BETTER_AUTH_TRUSTED_ORIGINS=https://app.example.com,https://admin.example.com
Wildcard Patterns
trustedOrigins: [
"*.example.com", // Matches any subdomain
"https://*.example.com", // Protocol-specific wildcard
"exp://192.168.*.*:*/*", // Custom schemes (e.g., Expo)
]
Dynamic Trusted Origins
Compute trusted origins based on the request:
trustedOrigins: async (request) => {
// Validate against database, header, etc.
const tenant = getTenantFromRequest(request);
return [`https://${tenant}.myapp.com`];
}
Validates callbackURL, redirectTo, errorCallbackURL, newUserCallbackURL, and origin against trusted origins. Invalid URLs receive 403.
Do not trust localhost in production
trustedOrigins gates redirect/callback URLs, not only cookie CSRF, so a
permanent localhost entry in a production list widens the open-redirect
surface (and Better Auth's docs warn against it). Derive the dev-vs-prod fork
from the deployment's own origin (its baked baseURL / resolved env origin),
never from the request, and reuse the same fork as the cookie config:
// localhost dev origins are trusted ONLY on a local deployment.
function buildTrustedOrigins(baseURL: string): string[] {
const prod = [...productionOrigins];
return isLocalDeployment(baseURL) ? [...prod, ...devOrigins] : prod;
}
Session Security
Session Expiration
import { betterAuth } from "better-auth";
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days (default)
updateAge: 60 * 60 * 24, // Refresh session every 24 hours (default)
},
});
Session Caching Strategies
Cache session data in cookies to reduce database queries:
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
strategy: "compact", // Options: "compact", "jwt", "jwe"
},
}
Strategies: "compact" (Base64url + HMAC, smallest), "jwt" (HS256, standard), "jwe" (encrypted, use when session has sensitive data).
Cookie Security
Defaults: secure: true (HTTPS/production), sameSite: "lax", httpOnly: true, path: "/", prefix __Secure-.
Custom Cookie Configuration
import { betterAuth } from "better-auth";
export const auth = betterAuth({
advanced: {
useSecureCookies: true, // Force secure cookies
cookiePrefix: "myapp", // Custom prefix (default: "better-auth")
defaultCookieAttributes: {
sameSite: "strict", // Stricter CSRF protection
path: "/auth", // Limit cookie scope
},
},
});
Cross-Subdomain Cookies
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: ".example.com", // Note the leading dot
additionalCookies: ["session_token", "session_data"],
},
}
Only enable if you need authentication sharing and trust all subdomains.
Account Linking and Provider Trust
Implicit account linking is an account-takeover surface. When a social sign-in
matches an existing user by email, the link gate is (better-auth 1.5.6
oauth2/link-account):
block linking if: (!isTrustedProvider && !userInfo.emailVerified)
|| accountLinking.enabled === false
|| accountLinking.disableImplicitLinking === true
A provider in account.accountLinking.trustedProviders bypasses the incoming
emailVerified check. So the rule is:
trustedProvidersmay contain ONLY identity providers that always assert a verified email. Google does. GitHub does NOT (it can return an unverified primary email), so never addgithubtotrustedProviders; an untrusted GitHub identity still links when GitHub reports the email verified, which is the safe behavior.- Never list
email-passwordintrustedProviders, and do not enableemailAndPasswordwithoutemailVerification.sendVerificationEmail+requireEmailVerification. On better-auth versions before the unconditionalrequireLocalEmailVerifiedgate (e.g. 1.5.6 has no such option), an attacker can pre-register an unverified local account at a victim's email and have the victim's later trusted-provider sign-in link into it. - If you have no email sender, prefer social-IdP-only sign-in over local credentials. That is what closes the takeover at the root.
OAuth / Social Provider Security
PKCE is automatic for all OAuth flows. State tokens are 32-char random strings expiring after 10 minutes.
State Parameter Storage
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
storeStateStrategy: "cookie", // Options: "cookie" (default), "database"
},
});
Encrypting OAuth Tokens
account: {
encryptOAuthTokens: true, // Uses AES-256-GCM
}
Enable if storing OAuth tokens for API access on behalf of users. Use skipStateCookieCheck: true only for mobile apps that cannot maintain cookies.
IP-Based Security
IP Address Configuration
import { betterAuth } from "better-auth";
export const auth = betterAuth({
advanced: {
ipAddress: {
ipAddressHeaders: ["x-forwarded-for", "x-real-ip"], // Headers to check
disableIpTracking: false, // Keep enabled for rate limiting
},
},
});
Set ipv6Subnet (128, 64, 48, 32; default 64) to group IPv6 addresses. Enable trustedProxyHeaders: true only if behind a trusted reverse proxy.
Database Hooks for Security Auditing
import { betterAuth } from "better-auth";
export const auth = betterAuth({
databaseHooks: {
session: {
create: {
after: async ({ data, ctx }) => {
await auditLog("session.created", {
userId: data.userId,
ip: ctx?.request?.headers.get("x-forwarded-for"),
userAgent: ctx?.request?.headers.get("user-agent"),
});
},
},
delete: {
before: async ({ data }) => {
await auditLog("session.revoked", { sessionId: data.id });
},
},
},
user: {
update: {
after: async ({ data, oldData }) => {
if (oldData?.email !== data.email) {
await auditLog("user.email_changed", {
userId: data.id,
oldEmail: oldData?.email,
newEmail: data.email,
});
}
},
},
},
account: {
create: {
after: async ({ data }) => {
await auditLog("account.linked", {
userId: data.userId,
provider: data.providerId,
});
},
},
},
},
});
Return false from a before hook to prevent an operation.
Background Tasks
import { betterAuth } from "better-auth";
export const auth = betterAuth({
advanced: {
backgroundTasks: {
handler: (promise) => {
// Platform-specific handler
// Vercel: waitUntil(promise)
// Cloudflare: ctx.waitUntil(promise)
waitUntil(promise);
},
},
},
});
Ensures operations like sending emails don't affect response timing.
Account Enumeration Prevention
Built-in: consistent response messages, dummy operations on invalid requests, background email sending. Return generic error messages ("Invalid credentials") rather than specific ones ("User not found").
Complete Security Configuration Example
import { betterAuth } from "better-auth";
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET,
baseURL: "https://api.example.com",
trustedOrigins: [
"https://app.example.com",
"https://*.preview.example.com",
],
// Rate limiting
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 },
},
},
// Session security
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", // Encrypted session data
},
},
// OAuth security
account: {
encryptOAuthTokens: true,
storeStateStrategy: "cookie",
},
// Advanced settings
advanced: {
useSecureCookies: true,
cookiePrefix: "myapp",
defaultCookieAttributes: {
sameSite: "lax",
},
ipAddress: {
ipAddressHeaders: ["x-forwarded-for"],
ipv6Subnet: 64,
},
backgroundTasks: {
handler: (promise) => waitUntil(promise),
},
},
// Security auditing
databaseHooks: {
session: {
create: {
after: async ({ data, ctx }) => {
console.log(`New session for user ${data.userId}`);
},
},
},
user: {
update: {
after: async ({ data, oldData }) => {
if (oldData?.email !== data.email) {
console.log(`Email changed for user ${data.id}`);
}
},
},
},
},
});
Security Checklist
Before deploying to production:
- Secret: Use a strong, unique secret (32+ characters, high entropy)
- HTTPS: Ensure
baseURLuses HTTPS - Trusted Origins: Configure all valid origins (frontend, mobile apps)
- Rate Limiting: Keep enabled with appropriate limits
- CSRF Protection: Keep enabled (
disableCSRFCheck: false) - Secure Cookies: Enabled automatically with HTTPS
- OAuth Tokens: Consider
encryptOAuthTokens: trueif storing tokens - Background Tasks: Configure for serverless platforms
- Audit Logging: Implement via
databaseHooksorhooks - IP Tracking: Configure headers if behind a proxy