| 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,
});
Better Auth looks for secrets in this order:
options.secret in your config
BETTER_AUTH_SECRET environment variable
AUTH_SECRET environment 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,
window: 10,
max: 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) => {
},
set: async (key, 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,
max: 5,
},
"/api/auth/some-safe-endpoint": false,
},
}
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,
},
});
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",
"https://*.example.com",
"exp://192.168.*.*:*/*",
]
Dynamic Trusted Origins
Compute trusted origins based on the request:
trustedOrigins: async (request) => {
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:
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,
updateAge: 60 * 60 * 24,
},
});
Session Caching Strategies
Cache session data in cookies to reduce database queries:
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5,
strategy: "compact",
},
}
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,
cookiePrefix: "myapp",
defaultCookieAttributes: {
sameSite: "strict",
path: "/auth",
},
},
});
Cross-Subdomain Cookies
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: ".example.com",
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:
trustedProviders may contain ONLY identity providers that always assert a
verified email. Google does. GitHub does NOT (it can return an unverified
primary email), so never add github to trustedProviders; an untrusted
GitHub identity still links when GitHub reports the email verified, which is
the safe behavior.
- Never list
email-password in trustedProviders, and do not enable
emailAndPassword without emailVerification.sendVerificationEmail +
requireEmailVerification. On better-auth versions before the unconditional
requireLocalEmailVerified gate (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",
},
});
Encrypting OAuth Tokens
account: {
encryptOAuthTokens: true,
}
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"],
disableIpTracking: false,
},
},
});
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) => {
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",
],
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: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 60 * 60,
cookieCache: {
enabled: true,
maxAge: 300,
strategy: "jwe",
},
},
account: {
encryptOAuthTokens: true,
storeStateStrategy: "cookie",
},
advanced: {
useSecureCookies: true,
cookiePrefix: "myapp",
defaultCookieAttributes: {
sameSite: "lax",
},
ipAddress: {
ipAddressHeaders: ["x-forwarded-for"],
ipv6Subnet: 64,
},
backgroundTasks: {
handler: (promise) => waitUntil(promise),
},
},
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: