.cursor/skills/better-auth-security-best-practices/SKILL.md
This skill provides guidance for implementing security features that span across Better Auth, including rate limiting, CSRF protection, session security, trusted origins, secret management, OAuth security, IP tracking, and security auditing. These topics are not covered in individual plugin skills.
npx skillsauth add akghosh111/scyra better-auth-security-best-practicesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The auth secret is the foundation of Better Auth's security. It's used for signing session tokens, encrypting sensitive data, and generating secure cookies.
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 variableBetter Auth validates your secret and will:
Generate a secure secret:
openssl rand -base64 32
Important: Never commit secrets to version control. Use environment variables or a secrets manager.
Rate limiting protects your authentication endpoints from brute-force attacks and abuse.
By default, rate limiting is enabled in production but disabled in development. To explicitly enable it, set rateLimit.enabled to true in your auth config.
Better Auth applies rate limiting to all endpoints by default.
Each plugin can optionally have it's own configuration to adjust rate-limit rules for a given 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)
},
});
Configure where rate limit counters are stored:
rateLimit: {
storage: "database", // Options: "memory", "database", "secondary-storage"
}
memory: Fast, but resets on server restart (default when no secondary storage)database: Persistent, but adds database loadsecondary-storage: Uses configured secondary storage like Redis (default when available)Note: It is not recommended to use memory especially on serverless platforms.
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
},
},
}
Better Auth applies stricter limits to sensitive endpoints by default:
/sign-in, /sign-up, /change-password, /change-email: 3 requests per 10 secondsOverride or customize rules for specific paths:
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
},
}
Better Auth implements multiple layers of CSRF protection to prevent cross-site request forgery attacks.
Origin or Referer header must match a trusted originSec-Fetch-Site, Sec-Fetch-Mode, and Sec-Fetch-Dest headers to detect cross-site requestsimport { betterAuth } from "better-auth";
export const auth = betterAuth({
advanced: {
disableCSRFCheck: false, // Default: false (keep enabled)
},
});
Warning: Only disable CSRF protection for testing or if you have an alternative CSRF mechanism in place.
Better Auth automatically blocks requests where:
Sec-Fetch-Site: cross-site ANDSec-Fetch-Mode: navigate ANDSec-Fetch-Dest: documentThis prevents form-based CSRF attacks even on first login when no session cookie exists.
Trusted origins control which domains can make authenticated requests to your Better Auth instance. This protects against open redirect attacks and cross-origin abuse.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
baseURL: "https://api.example.com",
trustedOrigins: [
"https://app.example.com",
"https://admin.example.com",
],
});
Note: The baseURL origin is automatically trusted.
Set trusted origins via environment variable (comma-separated):
BETTER_AUTH_TRUSTED_ORIGINS=https://app.example.com,https://admin.example.com
Support for subdomain wildcards:
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`];
}
Better Auth validates these URL parameters against trusted origins:
callbackURL - Where to redirect after authenticationredirectTo - General redirect parametererrorCallbackURL - Where to redirect on errorsnewUserCallbackURL - Where to redirect new usersorigin - Request origin headerInvalid URLs receive a 403 Forbidden response.
Sessions control how long users stay authenticated and how session data is secured.
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)
},
});
The freshAge setting defines how recently a user must have authenticated to perform sensitive operations:
session: {
freshAge: 60 * 60 * 24, // 24 hours (default)
}
Use this to require re-authentication for actions like changing passwords or viewing sensitive data.
Cache session data in cookies to reduce database queries:
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
strategy: "compact", // Options: "compact", "jwt", "jwe"
},
}
compact: Base64url + HMAC-SHA256 (smallest, signed)jwt: HS256 JWT (standard, signed)jwe: A256CBC-HS512 encrypted (largest, encrypted)Note: Use jwe strategy when session data contains sensitive information that shouldn't be readable client-side.
Better Auth uses secure cookie defaults but allows customization for specific deployment scenarios.
secure: true when baseURL uses HTTPS or in productionsameSite: "lax" (prevents CSRF while allowing normal navigation)httpOnly: true (prevents JavaScript access)path: "/" (available site-wide)__Secure- when secure is enabledimport { 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
},
},
});
Customize specific cookies:
advanced: {
cookies: {
session_token: {
name: "auth-session",
attributes: {
sameSite: "strict",
},
},
},
}
Share authentication across subdomains:
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: ".example.com", // Note the leading dot
additionalCookies: ["session_token", "session_data"],
},
}
Security Note: Cross-subdomain cookies expand the attack surface. Only enable if you need authentication sharing and trust all subdomains.
When using social login providers, Better Auth implements industry-standard security measures.
Better Auth automatically uses PKCE for all OAuth flows:
code_verifiercode_challenge using S256 (SHA-256)code_challenge_method: "S256" in the authorization URLThis prevents authorization code interception attacks.
The state parameter prevents CSRF attacks on OAuth callbacks:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
account: {
storeStateStrategy: "cookie", // Options: "cookie" (default), "database"
},
});
State tokens:
Encrypt stored access and refresh tokens in the database:
account: {
encryptOAuthTokens: true, // Uses AES-256-GCM
}
Recommendation: Enable this if you store OAuth tokens for API access on behalf of users.
For mobile apps or specific OAuth flows where cookies aren't available:
account: {
skipStateCookieCheck: true, // Not recommended for web apps
}
Warning: Only use this for mobile apps that cannot maintain cookies across redirects.
Better Auth tracks IP addresses for rate limiting and session security.
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
},
},
});
For rate limiting, IPv6 addresses can be grouped by subnet:
advanced: {
ipAddress: {
ipv6Subnet: 64, // Options: 128, 64, 48, 32 (default: 64)
},
}
Smaller values group more addresses together, which is useful when users share IPv6 prefixes.
When behind a reverse proxy, enable trusted headers:
advanced: {
trustedProxyHeaders: true, // Trust x-forwarded-host, x-forwarded-proto
}
Security Note: Only enable this if you trust your proxy. Malicious clients could spoof these headers otherwise.
Use database hooks to implement security auditing and monitoring.
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:
databaseHooks: {
user: {
delete: {
before: async ({ data }) => {
// Prevent deletion of protected users
if (protectedUserIds.includes(data.id)) {
return false;
}
},
},
},
}
Sensitive operations should complete in constant time to prevent timing attacks.
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);
},
},
},
});
This ensures operations like sending emails don't affect response timing, which could leak information about whether a user exists.
Better Auth implements several measures to prevent attackers from discovering valid accounts.
For sign-up and sign-in endpoints, consider:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
// Generic error messages (implement in your error handling)
},
});
Return generic error messages like "Invalid credentials" rather than "User not found" or "Incorrect password".
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 hooksdevelopment
Complete guide for setting up and handling Dodo Payments webhooks for real-time payment event notifications.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
tools
This skill provides guidance and enforcement rules for implementing secure two-factor authentication (2FA) using Better Auth's twoFactor plugin.
documentation
Guide for implementing subscription billing with Dodo Payments - trials, upgrades, downgrades, and on-demand billing.