Configuration
The betterAuth() instance — providers, plugins, sessions, rate limiting, email delivery, and the validated env schema
The entire auth surface is declared in one place: the betterAuth({...}) call in packages/auth/server.ts. This page walks through that configuration option by option. The values that control timing (session length, OTP/magic-link expiry) live as named constants in packages/utils/auth.ts so the server config reads declaratively.
Environment & startup validation
Before the instance is built, keys() (packages/auth/keys.ts) validates the environment with Zod via @t3-oss/env-nextjs. This throws at startup rather than failing lazily at request time:
const env = keys(); // throws if BETTER_AUTH_SECRET is missing / < 32 chars, etc.| Var | Schema | Notes |
|---|---|---|
BETTER_AUTH_SECRET | string().min(32) | Required. Signs cookies/tokens. |
BETTER_AUTH_URL | url().optional() | Base URL; falls back to NEXT_PUBLIC_APP_URL. |
BETTER_AUTH_TRUSTED_ORIGINS | string().optional() | Comma-separated; split into trustedOrigins. |
ADMIN_EMAILS | string().optional() | Comma-separated emails promoted to platform admin at signup (first-admin bootstrap; see below). |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | optional | Google OAuth. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | optional | GitHub OAuth. |
STRIPE_* | optional | Billing — see Payment. |
MAX_ORGS_PER_USER | coerce.number().int().positive() | Defaults to the @repo/utils constant. |
NEXT_PUBLIC_APP_URL / NEXT_PUBLIC_APP_NAME | optional | Client-visible app identity. |
Note that OAuth and Stripe keys are optional — the app boots without them. Social buttons always render; a click runs signInSocialAction and surfaces an error toast if the keys are empty (the buttons are not inert UI). The Stripe plugin is omitted entirely when keys are missing; see below.
Core instance options
export const auth = betterAuth({
appName: env.NEXT_PUBLIC_APP_NAME,
baseURL: env.BETTER_AUTH_URL || env.NEXT_PUBLIC_APP_URL,
secret: env.BETTER_AUTH_SECRET,
trustedOrigins: env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? [],
database: prismaAdapter(database, { provider: "postgresql" }),
advanced: { useSecureCookies: process.env.NODE_ENV === "production" },
// ...
});database— the Prisma adapter against PostgreSQL. All auth tables are part of the shared@repo/databaseschema.advanced.useSecureCookies—trueonly in production, so cookies work over plainhttp://localhostin dev.user.additionalFields— extends the user with optionalfirstName/lastName.account.accountLinking— enabled withtrustedProviders: ["google", "github"], so signing in with a social provider that matches an existing verified email links to that account instead of creating a duplicate.
Email & password
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => { /* @repo/email */ },
},requireEmailVerification: trueis the key gate — a freshly registered user cannot sign in until they verify (see Sign Up & Email Verification).revokeSessionsOnPasswordReset: trueinvalidates all existing sessions when the password is reset, so a stolen session can't outlive a reset (see Password Reset).
Social providers
socialProviders: {
google: { clientId: env.GOOGLE_CLIENT_ID || "", clientSecret: env.GOOGLE_CLIENT_SECRET || "" },
github: { clientId: env.GITHUB_CLIENT_ID || "", clientSecret: env.GITHUB_CLIENT_SECRET || "" },
},Both are always declared; absent keys collapse to empty strings, so the provider exists but cannot complete a real OAuth round-trip until configured.
Sessions
session: {
expiresIn: SESSION_EXPIRES_IN_DAYS * SECONDS_PER_DAY, // 7 days
updateAge: SESSION_UPDATE_AGE_DAYS * SECONDS_PER_DAY, // 1 day
cookieCache: { enabled: true, maxAge: COOKIE_CACHE_MAX_AGE_MINUTES * SECONDS_PER_MINUTE }, // 5 min
},See Sessions & Route Protection for how the lifetime and cookie cache are actually used by the edge middleware and server helpers.
Rate limiting
rateLimit: {
storage: "database",
customRules: {
"/sign-in/email": { window: 60, max: 5 },
"/sign-up/email": { window: 60, max: 3 },
"/forgot-password": { window: 60, max: 3 },
"/reset-password": { window: 60, max: 3 },
"/change-password": { window: 60, max: 3 },
"/two-factor/*": { window: 60, max: 5 },
},
},Rate limit state is stored in the database (the RateLimit model) rather than memory — this matters on serverless/Vercel, where in-memory counters wouldn't survive between invocations or be shared across instances. The custom rules throttle the abuse-prone endpoints (credential stuffing on sign-in, reset-email flooding, OTP brute-forcing).
Plugins
The plugins array is the feature set. Server plugins and their client counterparts (packages/auth/client.ts) must stay in sync.
plugins: [
admin({ ac, roles, defaultRole: ADMIN_ROLES.USER }),
magicLink({ sendMagicLink, expiresIn: MAGIC_LINK_EXPIRES_IN_MINUTES * SECONDS_PER_MINUTE }),
emailOTP({ sendVerificationOTP, otpLength: OTP_LENGTH, expiresIn: OTP_EXPIRES_IN_MINUTES * SECONDS_PER_MINUTE }),
twoFactor({ issuer: env.NEXT_PUBLIC_APP_NAME, otpOptions: { sendOTP, period, digits } }),
multiSession(),
organization({ ac: orgAc, roles, creatorRole, memberRole, organizationLimit, allowUserToCreateOrganization, ... }),
...(stripeClient && env.STRIPE_WEBHOOK_SECRET ? [stripe({ ... })] : []),
i18n({ translations: baErrorTranslations, defaultLocale, detection, localeCookie, getLocale }),
nextCookies(), // must stay last
],| Plugin | Purpose | Timing constant |
|---|---|---|
admin | Platform RBAC; assigns defaultRole: "user" at signup | — |
magicLink | Passwordless email links | MAGIC_LINK_EXPIRES_IN_MINUTES (10) |
emailOTP | One-time codes for sign-in, email verification, and forget-password | OTP_EXPIRES_IN_MINUTES (5), OTP_LENGTH (6) |
twoFactor | TOTP + email-OTP second factor | TWO_FACTOR_OTP_PERIOD_MINUTES (5), TWO_FACTOR_DIGITS (6) |
multiSession | Multiple concurrent device sessions | — |
organization | Orgs, members, teams, invitations | INVITATION_EXPIRES_IN_DAYS (7) |
stripe | Billing (conditional) | — |
@better-auth/i18n (i18n({...})) | Localizes Better Auth API errors | — |
nextCookies | Wires Better Auth cookie handling into Next.js. Must stay last in the plugins array — it wraps the response to set cookies, so any plugin appended after it is not covered. | — |
First-admin bootstrap (ADMIN_EMAILS)
The admin plugin assigns defaultRole: "user" at signup, and the only role-change endpoint (/admin/set-role) requires an existing admin — so a fresh deployment has no way to mint its first admin. ADMIN_EMAILS solves this: a databaseHooks.user.create.before hook in packages/auth/server.ts promotes any signup whose email matches the comma-separated list (case-insensitive) to role: "admin". It covers every signup method — email/password, OAuth, magic link, email OTP — since all funnel through the same user create.
Rules of engagement:
- Applies at signup only. Set the var before the first sign-up; changing it later does not touch existing users. To promote an existing user, use the admin UI as an existing admin (or update the
user.rolecolumn directly). - Verification still gates access. With
requireEmailVerificationenabled, a matching signup cannot sign in until the email is verified — listing an address you don't control does not hand out a session. - The dev seed (
packages/database/prisma/seed.ts) createsadmin@example.comdirectly, so local development does not need this var.
Email delivery is dependency-injected
Better Auth never sends email itself — each plugin receives a send* callback that delegates to @repo/email. This keeps the auth package transport-agnostic (Resend in production, MailDev locally):
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
if (type === "sign-in") await sendSignInOTPEmail({ email, otp, ... });
else if (type === "email-verification") await sendVerificationOTPEmail({ email, otp, ... });
else /* forget-password */ await sendSignInOTPEmail({ email, otp, ... });
},
...
})The full set of injected senders: sendPasswordResetEmail, sendEmailVerificationEmail, sendMagicLinkEmail, sendSignInOTPEmail, sendVerificationOTPEmail, sendTwoFactorOTPEmail, and sendOrgInvitationEmail.
The Stripe plugin is conditional
...(stripeClient && env.STRIPE_WEBHOOK_SECRET ? [stripe({ ... })] : [])The billing plugin is only added when both a Stripe client and a webhook secret are configured. Without them, the auth instance runs identically minus billing — useful for local dev and CI. See the Payment section.
organization and self-serve org creation
The organization plugin gates new-org creation behind allowUserToCreateOrganization: (user) => canUserCreateOrg(user.id, MAX_ORGS_PER_USER) (packages/auth/org-eligibility.ts). That helper counts the user's owned orgs and their active free-plan subscriptions, then defers to checkOrgCreationEligibility(...) against MAX_ORGS_PER_USER. Role assignment (creatorRole: "owner", memberRole: "member") and the access-control matrix are defined in packages/auth/permissions.ts.
Client instance
The browser side (packages/auth/client.ts) is a "use client" module that registers the matching plugins and re-exports bound methods, so components import ready-to-call functions:
export const { signIn, signUp, signOut, useSession, getSession, changePassword,
twoFactor, organization, useActiveOrganization, subscription, emailOtp, admin } = authClient;The client registers a matching i18nClient() and a bare twoFactorClient() (no onTwoFactorRedirect). Password 2FA is a server-action path — covered in Two-Factor Authentication.
The Next.js handler
All of this is exposed through a single catch-all route:
// apps/web/app/api/auth/[...all]/route.ts
import { auth } from "@repo/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
const { GET, POST } = toNextJsHandler(auth);
export { GET, POST };toNextJsHandler maps every Better Auth endpoint (and every plugin's endpoints, including the Stripe webhook) onto /api/auth/*. There are no per-flow API routes to maintain.
Source files
packages/auth/server.ts— thebetterAuth({...})instancepackages/auth/client.ts—createAuthClient({...})and method exportspackages/auth/keys.ts— Zod env schemapackages/auth/org-eligibility.ts—canUserCreateOrg(userId, maxOrgsPerUser)packages/utils/auth.ts— timing constantsapps/web/app/api/auth/[...all]/route.ts— the mounted handler