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.
68
82%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
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.
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.secret in your configBETTER_AUTH_SECRET environment variableAUTH_SECRET environment variableopenssl rand -base64 32Enabled in production by default. Applies to all endpoints. Plugins can override per-endpoint.
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)
},
});Options: "memory" (resets on restart, avoid on serverless), "database" (persistent), "secondary-storage" (Redis, default when available).
rateLimit: {
storage: "database",
}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
},
},
}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
},
}Multi-layer protection: origin header validation, Fetch Metadata checks, and first-login protection.
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.
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
trustedOrigins: [
"*.example.com", // Matches any subdomain
"https://*.example.com", // Protocol-specific wildcard
"exp://192.168.*.*:*/*", // Custom schemes (e.g., Expo)
]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.
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;
}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)
},
});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).
Defaults: secure: true (HTTPS/production), sameSite: "lax", httpOnly: true, path: "/", prefix __Secure-.
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
},
},
});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.
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 === trueA 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.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.PKCE is automatic for all OAuth flows. State tokens are 32-char random strings expiring after 10 minutes.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
storeStateStrategy: "cookie", // Options: "cookie" (default), "database"
},
});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.
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.
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.
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.
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").
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}`);
}
},
},
},
},
});Before deploying to production:
baseURL uses HTTPSdisableCSRFCheck: false)encryptOAuthTokens: true if storing tokensdatabaseHooks or hookscb12bcc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.