Awesome-omni-skill better-auth-patterns

Better Auth authentication patterns for TypeScript applications. Use when implementing authentication with Better Auth, configuring OAuth providers, setting up session management, integrating with Next.js/Astro/Hono/Express/TanStack Start, or configuring Drizzle/Prisma adapters.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/better-auth-patterns-qazuor" ~/.claude/skills/diegosouzapw-awesome-omni-skill-better-auth-patterns && rm -rf "$T"
manifest: skills/development/better-auth-patterns-qazuor/SKILL.md
source content

Better Auth Patterns

Purpose

Comprehensive patterns for implementing authentication with Better Auth across frameworks. Covers server and client setup, database adapters, OAuth providers, session management, middleware, plugins (2FA, admin, organization, magic link, passkey, API keys), and production security hardening.

Server Setup

Core Configuration

import { betterAuth } from "better-auth";

export const auth = betterAuth({
  appName: "My App",
  baseURL: process.env.BETTER_AUTH_URL,        // required in production
  basePath: "/api/auth",                        // default mount path
  secret: process.env.BETTER_AUTH_SECRET,       // min 32 chars, generate: openssl rand -base64 32
  database: /* adapter — see Database section */,
  trustedOrigins: ["https://example.com"],      // required in production
  emailAndPassword: { enabled: true },
  socialProviders: { /* see OAuth section */ },
  plugins: [],
});

Environment variables:

BETTER_AUTH_SECRET=<openssl rand -base64 32>   # required
BETTER_AUTH_URL=http://localhost:3000           # required

Next.js (App Router)

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth);

For server actions that set cookies, add the

nextCookies
plugin (must be last):

import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";

export const auth = betterAuth({
  plugins: [nextCookies()],  // must be last plugin
});

Next.js (Pages Router)

// pages/api/auth/[...all].ts
import { toNodeHandler } from "better-auth/node";
import { auth } from "@/lib/auth";

export const config = { api: { bodyParser: false } };
export default toNodeHandler(auth.handler);

Astro

// pages/api/auth/[...all].ts
import { auth } from "~/auth";
import type { APIRoute } from "astro";

export const ALL: APIRoute = async (ctx) => {
  return auth.handler(ctx.request);
};

Hono

import { Hono } from "hono";
import { cors } from "hono/cors";
import { auth } from "./auth";

const app = new Hono();

// CORS must be registered before routes
app.use("/api/auth/*", cors({
  origin: "http://localhost:3001",
  allowHeaders: ["Content-Type", "Authorization"],
  allowMethods: ["POST", "GET", "OPTIONS"],
  credentials: true,
}));

app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));

Express

import express from "express";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth";

const app = express();

// Better Auth handler MUST come before express.json()
app.all("/api/auth/*splat", toNodeHandler(auth));  // v5 syntax
app.use(express.json());

TanStack Start

// src/routes/api/auth/$.ts
import { auth } from "@/lib/auth";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/api/auth/$")({
  server: {
    handlers: {
      GET: async ({ request }: { request: Request }) => auth.handler(request),
      POST: async ({ request }: { request: Request }) => auth.handler(request),
    },
  },
});

Requires

tanstackStartCookies()
plugin (must be last):

import { tanstackStartCookies } from "better-auth/tanstack-start";

export const auth = betterAuth({
  plugins: [tanstackStartCookies()],
});

SolidStart

// routes/api/auth/*auth.ts
import { auth } from "~/lib/auth";
import { toSolidStartHandler } from "better-auth/solid-start";

export const { GET, POST } = toSolidStartHandler(auth);

Nuxt

// server/api/auth/[...all].ts
import { auth } from "~/lib/auth";

export default defineEventHandler((event) => {
  return auth.handler(toWebRequest(event));
});

Cloudflare Workers

Requires

compatibility_flags = ["nodejs_compat"]
in
wrangler.toml
.

export default {
  async fetch(request: Request) {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/api/auth")) {
      return auth.handler(request);
    }
    return new Response("Not found", { status: 404 });
  },
};

Client Setup

Framework-Specific Imports

import { createAuthClient } from "better-auth/client";   // Vanilla JS
import { createAuthClient } from "better-auth/react";     // React / Next.js
import { createAuthClient } from "better-auth/vue";       // Vue / Nuxt
import { createAuthClient } from "better-auth/svelte";    // Svelte / SvelteKit
import { createAuthClient } from "better-auth/solid";     // Solid / SolidStart

Client Configuration

export const authClient = createAuthClient({
  baseURL: "http://localhost:3000",   // only needed if server is on a different domain
  plugins: [],
});

Core Client Methods

// Sign up
const { data, error } = await authClient.signUp.email({
  email: "user@example.com",
  password: "password1234",
  name: "User Name",
  callbackURL: "/dashboard",
});

// Sign in (email)
const { data, error } = await authClient.signIn.email({
  email: "user@example.com",
  password: "password1234",
  rememberMe: true,
});

// Sign in (social — redirects to provider)
await authClient.signIn.social({
  provider: "google",
  callbackURL: "/dashboard",
  errorCallbackURL: "/error",
  newUserCallbackURL: "/welcome",
});

// Sign out
await authClient.signOut({
  fetchOptions: { onSuccess: () => router.push("/login") },
});

// Get session (one-shot)
const { data: session } = await authClient.getSession();

// Use session (reactive hook for React/Vue/Svelte/Solid)
const { data: session, isPending, error } = authClient.useSession();

// Update user
await authClient.updateUser({ name: "New Name" });

Per-Request Callbacks

await authClient.signIn.email({ email, password }, {
  onRequest: (ctx) => { /* show loading */ },
  onSuccess: (ctx) => { /* redirect */ },
  onError: (ctx) => { alert(ctx.error.message); },
});

Server-Side API

All client endpoints are callable on the server via

auth.api
:

// Get session (pass framework headers)
const session = await auth.api.getSession({ headers: await headers() });

// Sign in
const data = await auth.api.signInEmail({
  body: { email: "user@example.com", password: "password" },
  headers: await headers(),
});

Server-side calls are NOT subject to rate limiting.

Database Configuration

Drizzle Adapter

import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./database";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",  // "pg" | "mysql" | "sqlite"
  }),
});

Generate schema and migrate:

npx @better-auth/cli generate    # generates auth-schema.ts
npx drizzle-kit generate         # generates migration
npx drizzle-kit migrate          # applies migration

Custom table names:

import * as schema from "./schema";

database: drizzleAdapter(db, {
  provider: "pg",
  schema: { ...schema, user: schema.users },  // map user -> users table
}),

Prisma Adapter

import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",  // "postgresql" | "mysql" | "sqlite"
  }),
});

Generate schema and migrate:

npx @better-auth/cli generate          # adds models to schema.prisma
npx prisma migrate dev --name auth
npx prisma generate

Direct Database Drivers

// SQLite
import Database from "better-sqlite3";
database: new Database("./sqlite.db")

// PostgreSQL
import { Pool } from "pg";
database: new Pool({ connectionString: process.env.DATABASE_URL })

// MySQL
import { createPool } from "mysql2/promise";
database: createPool({ uri: process.env.DATABASE_URL })

Core Schema (4 tables)

TableKey Fields
userid, name, email, emailVerified, image, createdAt, updatedAt
sessionid, userId (FK), token, expiresAt, ipAddress, userAgent, createdAt, updatedAt
accountid, userId (FK), accountId, providerId, accessToken, refreshToken, password, createdAt, updatedAt
verificationid, identifier, value, expiresAt, createdAt, updatedAt

Additional User Fields

user: {
  additionalFields: {
    role: {
      type: "string",
      required: false,
      defaultValue: "user",
      input: false,  // prevents user-provided values during signup
    },
    lang: {
      type: "string",
      required: false,
      defaultValue: "en",
    },
  },
},

Field

type
options:
"string"
,
"number"
,
"boolean"
,
"date"
, or string array for enums (e.g.,
["user", "admin"]
).

Secondary Storage (Redis)

secondaryStorage: {
  get: async (key) => await redis.get(key),
  set: async (key, value, ttl) => {
    if (ttl) await redis.set(key, value, { EX: ttl });
    else await redis.set(key, value);
  },
  delete: async (key) => await redis.del(key),
},

Custom Table/Column Names

user: {
  modelName: "users",
  fields: { name: "full_name", email: "email_address" },
},
session: {
  modelName: "user_sessions",
  fields: { userId: "user_id" },
},

Email and Password Authentication

Server Configuration

emailAndPassword: {
  enabled: true,
  minPasswordLength: 8,
  maxPasswordLength: 128,
  autoSignIn: true,           // auto sign in after sign up
  requireEmailVerification: false,
  sendResetPassword: async ({ user, url, token }, request) => {
    // Don't await — prevents timing attacks
    void sendEmail({
      to: user.email,
      subject: "Reset your password",
      text: `Click to reset: ${url}`,
    });
  },
},

Email Verification

emailVerification: {
  sendVerificationEmail: async ({ user, url, token }, request) => {
    void sendEmail({
      to: user.email,
      subject: "Verify your email",
      text: `Click to verify: ${url}`,
    });
  },
  sendOnSignUp: true,
  autoSignInAfterVerification: true,
  expiresIn: 3600,
},

Password Reset Flow

// Client: request reset
await authClient.requestPasswordReset({
  email: "user@example.com",
  redirectTo: "/reset-password",
});

// Client: reset password (from reset page with token)
await authClient.resetPassword({
  newPassword: "newPassword1234",
  token: tokenFromUrl,
});

// Client: change password (authenticated)
await authClient.changePassword({
  currentPassword: "old1234",
  newPassword: "new1234",
  revokeOtherSessions: true,
});

Custom Password Hashing (Argon2)

import { hash, verify } from "@node-rs/argon2";

emailAndPassword: {
  enabled: true,
  password: {
    hash: (password) => hash(password, {
      memoryCost: 65536, timeCost: 3, parallelism: 4, outputLen: 32,
    }),
    verify: ({ password, hash: h }) => verify(h, password),
  },
},

OAuth / Social Authentication

Provider Configuration

socialProviders: {
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID as string,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    prompt: "select_account",
    accessType: "offline",  // for refresh tokens
  },
  github: {
    clientId: process.env.GITHUB_CLIENT_ID as string,
    clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
  },
  discord: {
    clientId: process.env.DISCORD_CLIENT_ID as string,
    clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
  },
},

Callback URL pattern:

{baseURL}/api/auth/callback/{provider}

35+ built-in providers: Apple, Discord, Facebook, GitHub, GitLab, Google, LinkedIn, Microsoft, Slack, Spotify, TikTok, Twitch, Twitter, and more.

Per-Provider Options

google: {
  clientId: "...",
  clientSecret: "...",
  scope: ["https://www.googleapis.com/auth/drive.file"],
  mapProfileToUser: (profile) => ({
    name: profile.name,
    image: profile.picture,
  }),
  disableSignUp: false,
  overrideUserInfoOnSignIn: false,
},

Client-Side Social Sign In

// Redirect-based (default)
await authClient.signIn.social({
  provider: "google",
  callbackURL: "/dashboard",
});

// ID Token-based (no redirect — for mobile/native)
await authClient.signIn.social({
  provider: "google",
  idToken: { token: googleIdToken, accessToken: googleAccessToken },
});

Generic OAuth (Custom Providers)

import { genericOAuth } from "better-auth/plugins";

plugins: [
  genericOAuth({
    config: [{
      providerId: "keycloak",
      clientId: "...",
      clientSecret: "...",
      discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
      scopes: ["openid", "profile", "email"],
    }],
  }),
],

Account Linking

account: {
  accountLinking: {
    enabled: true,
    trustedProviders: ["google", "github"],
    allowDifferentEmails: false,
  },
},

Session Management

Session Configuration

session: {
  expiresIn: 60 * 60 * 24 * 7,     // 7 days (seconds)
  updateAge: 60 * 60 * 24,           // refresh after 1 day
  freshAge: 60 * 60 * 24,            // fresh for 1 day (sensitive ops require fresh)
  disableSessionRefresh: false,
},

Cookie Cache (Performance)

session: {
  cookieCache: {
    enabled: true,
    maxAge: 5 * 60,          // 5 minutes
    strategy: "jwt",          // "compact" | "jwt" | "jwe"
    refreshCache: true,       // auto-refresh on expiry
  },
},
StrategySizeSecurity
compact
SmallestReadable, HMAC-SHA256 signed
jwt
MediumReadable, HS256 JWT, interoperable
jwe
LargestFully encrypted AES-256-GCM

Session Invalidation

Change

cookieCache.version
and redeploy to invalidate all sessions.

Client Session Methods

const sessions = await authClient.listSessions();
await authClient.revokeSession({ token: "session-token" });
await authClient.revokeOtherSessions();
await authClient.revokeSessions();  // revoke all

Middleware Patterns

Next.js Middleware (Cookie Check)

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  if (!sessionCookie) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*"] };

For full validation (not just cookie presence):

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

// In RSC or Server Action:
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");

Next.js Cookie Cache Middleware

import { getCookieCache } from "better-auth/cookies";

export async function middleware(request: NextRequest) {
  const session = await getCookieCache(request);
  if (!session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

Astro Middleware

// middleware.ts
import { auth } from "@/auth";
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  const isAuthed = await auth.api.getSession({
    headers: context.request.headers,
  });
  context.locals.user = isAuthed?.user || null;
  context.locals.session = isAuthed?.session || null;
  return next();
});

Hono Middleware (Type-Safe Context)

const app = new Hono<{
  Variables: {
    user: typeof auth.$Infer.Session.user | null;
    session: typeof auth.$Infer.Session.session | null;
  };
}>();

app.use("*", async (c, next) => {
  const session = await auth.api.getSession({
    headers: c.req.raw.headers,
  });
  c.set("user", session?.user ?? null);
  c.set("session", session?.session ?? null);
  await next();
});

// In routes:
app.get("/api/me", (c) => {
  const user = c.get("user");
  if (!user) return c.body(null, 401);
  return c.json(user);
});

Express Middleware

import { fromNodeHeaders } from "better-auth/node";

app.get("/api/me", async (req, res) => {
  const session = await auth.api.getSession({
    headers: fromNodeHeaders(req.headers),
  });
  if (!session) return res.status(401).json({ error: "Unauthorized" });
  return res.json(session);
});

TanStack Start Middleware

// src/middleware/auth.ts
import { redirect } from "@tanstack/react-router";
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { auth } from "@/lib/auth";

export const authMiddleware = createMiddleware().server(
  async ({ next }) => {
    const headers = getRequestHeaders();
    const session = await auth.api.getSession({ headers });
    if (!session) throw redirect({ to: "/login" });
    return await next();
  },
);

Nuxt Route Middleware

// middleware/auth.global.ts
import { authClient } from "~/lib/auth-client";

export default defineNuxtRouteMiddleware(async (to) => {
  const { data: session } = await authClient.useSession(useFetch);
  if (!session.value && to.path === "/dashboard") {
    return navigateTo("/login");
  }
});

Two-Factor Authentication Plugin

Setup

// Server
import { twoFactor } from "better-auth/plugins";

plugins: [
  twoFactor({
    issuer: "My App",   // shown in authenticator apps
    otpOptions: {
      async sendOTP({ user, otp }) {
        await sendEmail(user.email, `Your code: ${otp}`);
      },
    },
  }),
]

// Client
import { twoFactorClient } from "better-auth/client/plugins";

plugins: [
  twoFactorClient({
    onTwoFactorRedirect: () => { window.location.href = "/2fa"; },
  }),
]

Enable/Disable

await authClient.twoFactor.enable({ password: "user-password" });
await authClient.twoFactor.disable({ password: "user-password" });

Sign In Flow

await authClient.signIn.email({ email, password }, {
  onSuccess: (ctx) => {
    if (ctx.data.twoFactorRedirect) {
      router.push("/2fa");  // redirect to 2FA verification page
    }
  },
});

Verify TOTP

await authClient.twoFactor.verifyTotp({
  code: "123456",
  trustDevice: true,  // skip 2FA for 30 days on this device
});

Backup Codes

const { data } = await authClient.twoFactor.generateBackupCodes({ password });
// data.backupCodes — display to user for safekeeping

await authClient.twoFactor.verifyBackupCode({ code: "abc123" });

Database Schema

Adds

twoFactorEnabled
(boolean) to user table. Creates
twoFactor
table with
id
,
userId
,
secret
,
backupCodes
.

Admin Plugin

Setup

// Server
import { admin } from "better-auth/plugins";

plugins: [
  admin({
    defaultRole: "user",
    adminRoles: ["admin"],
    impersonationSessionDuration: 3600,
  }),
]

// Client
import { adminClient } from "better-auth/client/plugins";
plugins: [adminClient()]

Admin Operations

// Create user
await authClient.admin.createUser({
  email: "new@example.com",
  password: "password",
  name: "New User",
  role: "admin",
});

// List users (with filtering)
const { data } = await authClient.admin.listUsers({
  searchValue: "john",
  searchField: "email",
  searchOperator: "contains",
  limit: 50,
  sortBy: "createdAt",
  sortDirection: "desc",
});

// Ban/unban
await authClient.admin.banUser({ userId: "...", banReason: "Spam" });
await authClient.admin.unbanUser({ userId: "..." });

// Impersonate
await authClient.admin.impersonateUser({ userId: "..." });
await authClient.admin.stopImpersonating();

// Set role
await authClient.admin.setRole({ userId: "...", role: "admin" });

Custom Access Control

import { createAccessControl } from "better-auth/plugins/access";
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";

const statement = {
  ...defaultStatements,
  project: ["create", "update", "delete"],
} as const;

const ac = createAccessControl(statement);

const adminRole = ac.newRole({
  project: ["create", "update"],
  ...adminAc.statements,
});

// Server
plugins: [admin({ ac, roles: { admin: adminRole } })]

// Check permission
const can = await authClient.admin.hasPermission({
  permission: { project: ["create"] },
});

Database Schema

Adds to user table:

role
(string),
banned
(boolean),
banReason
(string),
banExpires
(date).

Organization Plugin

Setup

// Server
import { organization } from "better-auth/plugins";

plugins: [
  organization({
    allowUserToCreateOrganization: true,
    organizationLimit: 5,
    creatorRole: "owner",
    membershipLimit: 100,
    sendInvitationEmail: async ({ email, organization, inviter, url }) => {
      await sendEmail(email, `Join ${organization.name}: ${url}`);
    },
  }),
]

// Client
import { organizationClient } from "better-auth/client/plugins";
plugins: [organizationClient()]

Organization CRUD

await authClient.organization.create({ name: "Acme Inc", slug: "acme" });
await authClient.organization.update({ data: { name: "Acme Corp" } });
await authClient.organization.delete({ organizationId: "..." });
await authClient.organization.setActive({ organizationSlug: "acme" });

const { data } = authClient.useActiveOrganization();
const orgs = await authClient.organization.list({});

Member Management

await authClient.organization.inviteMember({
  email: "member@example.com",
  role: "member",
});
await authClient.organization.acceptInvitation({ invitationId: "..." });
await authClient.organization.removeMember({ memberIdOrEmail: "member@example.com" });
await authClient.organization.updateMemberRole({
  memberId: "...",
  role: "admin",
});

Default roles:

owner
(full control),
admin
(no delete org/change owner),
member
(read-only).

Teams (Sub-groups)

// Enable in config
organization({ teams: { enabled: true, maximumTeams: 10 } })

await authClient.organization.createTeam({ name: "Engineering" });
await authClient.organization.addTeamMember({ teamId: "...", userId: "..." });

Database Schema

Creates tables:

organization
,
member
,
invitation
, optionally
team
and
teamMember
.

Magic Link Plugin

Setup

// Server
import { magicLink } from "better-auth/plugins";

plugins: [
  magicLink({
    sendMagicLink: async ({ email, url, token }) => {
      await sendEmail(email, `Sign in: ${url}`);
    },
    expiresIn: 300,  // 5 minutes
  }),
]

// Client
import { magicLinkClient } from "better-auth/client/plugins";
plugins: [magicLinkClient()]

Usage

await authClient.signIn.magicLink({
  email: "user@example.com",
  callbackURL: "/dashboard",
});

// Verify (on callback page)
await authClient.magicLink.verify({ token: tokenFromUrl });

Passkey Plugin

Setup

npm install @better-auth/passkey
// Server
import { passkey } from "@better-auth/passkey";

plugins: [
  passkey({
    rpID: "example.com",
    rpName: "My App",
    origin: "https://example.com",
  }),
]

// Client
import { passkeyClient } from "@better-auth/passkey/client";
plugins: [passkeyClient()]

Usage

// Register passkey (must be authenticated)
await authClient.passkey.addPasskey({ name: "My Passkey" });

// Sign in with passkey
await authClient.signIn.passkey();

// Conditional UI (autofill)
await authClient.signIn.passkey({ autoFill: true });

// List and delete
const passkeys = await authClient.passkey.listUserPasskeys({});
await authClient.passkey.deletePasskey({ id: "..." });

API Key Plugin

Setup

// Server
import { apiKey } from "better-auth/plugins";

plugins: [
  apiKey({
    defaultPrefix: "sk_",
    defaultKeyLength: 64,
    enableMetadata: true,
    rateLimit: {
      enabled: true,
      timeWindow: 1000 * 60 * 60 * 24,
      maxRequests: 1000,
    },
  }),
]

// Client
import { apiKeyClient } from "better-auth/client/plugins";
plugins: [apiKeyClient()]

Usage

const { data } = await authClient.apiKey.create({
  name: "Production Key",
  expiresIn: 86400 * 30,  // 30 days
  prefix: "sk_live_",
});
// data.key — show ONCE, then it's hashed

const keys = await authClient.apiKey.list({});
await authClient.apiKey.delete({ keyId: "..." });

Verify in API routes

// Keys are sent in x-api-key header by default
const session = await auth.api.getSession({ headers: req.headers });

Bearer Token Plugin

// Server
import { bearer } from "better-auth/plugins";
plugins: [bearer()]

// Client: capture token after sign-in
const authClient = createAuthClient({
  fetchOptions: {
    onSuccess: (ctx) => {
      const token = ctx.response.headers.get("set-auth-token");
      if (token) localStorage.setItem("bearer_token", token);
    },
    auth: {
      type: "Bearer",
      token: () => localStorage.getItem("bearer_token") || "",
    },
  },
});

JWT Plugin

// Server
import { jwt } from "better-auth/plugins";

plugins: [
  jwt({
    jwt: {
      issuer: "https://example.com",
      audience: "https://example.com",
      expirationTime: "1h",
      definePayload: ({ user }) => ({
        id: user.id,
        email: user.email,
        role: user.role,
      }),
    },
  }),
]

// Client
import { jwtClient } from "better-auth/client/plugins";
plugins: [jwtClient()]

// Get JWT token
const { data } = await authClient.token();

JWKS endpoint exposed at

/api/auth/jwks
for token verification:

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://example.com/api/auth/jwks"));
const { payload } = await jwtVerify(token, JWKS);

Email OTP Plugin

// Server
import { emailOTP } from "better-auth/plugins";

plugins: [
  emailOTP({
    async sendVerificationOTP({ email, otp, type }) {
      await sendEmail(email, `Your code: ${otp}`);
    },
    otpLength: 6,
    expiresIn: 300,
  }),
]

// Client
import { emailOTPClient } from "better-auth/client/plugins";
plugins: [emailOTPClient()]

// Sign in flow
await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
await authClient.signIn.emailOtp({ email, otp: "123456" });

Username Plugin

// Server
import { username } from "better-auth/plugins";

plugins: [
  username({
    minUsernameLength: 3,
    maxUsernameLength: 30,
  }),
]

// Client
import { usernameClient } from "better-auth/client/plugins";
plugins: [usernameClient()]

// Sign in by username
await authClient.signIn.username({ username: "john", password: "..." });

// Check availability
await authClient.isUsernameAvailable({ username: "john" });

Phone Number Plugin

// Server
import { phoneNumber } from "better-auth/plugins";

plugins: [
  phoneNumber({
    sendOTP: async ({ phoneNumber, code }) => {
      await twilioClient.messages.create({
        body: `Your code: ${code}`,
        to: phoneNumber,
        from: "+1234567890",
      });
    },
  }),
]

// Client
import { phoneNumberClient } from "better-auth/client/plugins";
plugins: [phoneNumberClient()]

await authClient.phoneNumber.sendOtp({ phoneNumber: "+1234567890" });
await authClient.phoneNumber.verify({ phoneNumber: "+1234567890", code: "123456" });

Hooks and Lifecycle

Server Hooks (Before/After)

import { createAuthMiddleware, APIError } from "better-auth/api";

hooks: {
  before: createAuthMiddleware(async (ctx) => {
    // Access: ctx.path, ctx.body, ctx.query, ctx.headers
    if (ctx.path === "/sign-up/email" && blockedDomains.has(getDomain(ctx.body.email))) {
      throw new APIError("FORBIDDEN", { message: "Domain blocked" });
    }
  }),
  after: createAuthMiddleware(async (ctx) => {
    // ctx.context.newSession available after sign-up
    // ctx.context.returned for previous return value
  }),
},

Database Hooks

databaseHooks: {
  user: {
    create: {
      before: async (user) => ({
        data: { ...user, role: "user" },
      }),
      after: async (user) => {
        await analytics.track("user_created", { userId: user.id });
      },
    },
    delete: {
      before: async (user) => {
        if (user.role === "admin") return false;  // prevent deletion
        return true;
      },
    },
  },
},

Rate Limiting

rateLimit: {
  enabled: true,
  window: 60,
  max: 100,
  storage: "secondary-storage",  // use Redis for multi-instance
  customRules: {
    "/sign-in/email": { window: 10, max: 3 },
    "/two-factor/*": { window: 10, max: 3 },
    "/get-session": false,  // disable for session checks
  },
},

Built-in stricter limits:

/sign-in/email
(3/10s),
/two-factor/verify
(3/10s).

Client handling:

const authClient = createAuthClient({
  fetchOptions: {
    onError: (ctx) => {
      if (ctx.response.status === 429) {
        const retryAfter = ctx.response.headers.get("X-Retry-After");
      }
    },
  },
});

TypeScript Integration

Type Inference

// Server-side session type
type Session = typeof auth.$Infer.Session;  // { session, user }

// Client-side
type Session = typeof authClient.$Infer.Session;

Client-Side Additional Field Inference

// Same project (monorepo):
import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "./auth";

const authClient = createAuthClient({
  plugins: [inferAdditionalFields<typeof auth>()],
});

// Separate projects:
const authClient = createAuthClient({
  plugins: [
    inferAdditionalFields({
      user: { role: { type: "string" } },
    }),
  ],
});

Error Codes

const errorCodes = authClient.$ERROR_CODES;  // all possible error codes

TSConfig Requirements

{ "compilerOptions": { "strict": true } }

Do NOT enable both

declaration
and
composite
simultaneously.

Production Security Hardening

Required Configuration

export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
  trustedOrigins: ["https://example.com"],
  advanced: {
    useSecureCookies: true,
    defaultCookieAttributes: { httpOnly: true, secure: true },
    ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },
  },
  rateLimit: {
    enabled: true,
    storage: "secondary-storage",  // never use "memory" in production multi-instance
  },
  account: {
    encryptOAuthTokens: true,
  },
  session: {
    cookieCache: { enabled: true, strategy: "jwe" },  // encrypted cookie cache
  },
});

Serverless Background Tasks

import { waitUntil } from "@vercel/functions";

advanced: {
  backgroundTasks: { handler: waitUntil },
},

Cross-Subdomain Cookies

advanced: {
  crossSubDomainCookies: {
    enabled: true,
    domain: ".example.com",
  },
},

Production Checklist

  1. Set
    BETTER_AUTH_SECRET
    with high entropy (min 32 chars)
  2. Set
    BETTER_AUTH_URL
    explicitly (never infer from request)
  3. Configure
    trustedOrigins
    for all valid domains
  4. Enable rate limiting with Redis/database storage
  5. Never disable CSRF or origin checks
  6. Enable
    encryptOAuthTokens
    for OAuth token storage
  7. Use
    "jwe"
    cookie cache for maximum security
  8. Configure IP headers for your CDN/proxy
  9. Avoid awaiting email sends (timing attacks) — use
    void
    or
    waitUntil
  10. Use
    sessionCookie
    check in middleware, full
    getSession
    for protected operations

CLI Reference

npx @better-auth/cli init                      # initialize in project
npx @better-auth/cli generate --output ./db     # generate ORM schema
npx @better-auth/cli migrate                    # run migrations (Kysely only)
npx @better-auth/cli secret                     # generate a secret
npx @better-auth/cli info                       # diagnostic info

Best Practices

  • Always set
    BETTER_AUTH_SECRET
    and
    BETTER_AUTH_URL
    via environment variables, never hardcode
  • Use
    void sendEmail()
    in email callbacks to prevent timing attacks that reveal user existence
  • On serverless platforms, use
    waitUntil
    for background tasks like email delivery
  • Place
    express.json()
    middleware AFTER the Better Auth handler to avoid request body conflicts
  • Cookie-only middleware checks are fast but insufficient for sensitive operations; always call
    auth.api.getSession()
    for protected data
  • Use
    "secondary-storage"
    (Redis) for rate limiting and sessions in multi-instance production deployments
  • Enable
    encryptOAuthTokens
    when storing OAuth tokens to protect against database breaches
  • The
    nextCookies
    and
    tanstackStartCookies
    plugins must always be the LAST plugin in the array
  • Run
    npx @better-auth/cli generate
    after adding plugins to update your database schema
  • Use
    input: false
    on
    additionalFields
    for server-only fields like
    role
    to prevent user manipulation during signup
  • Configure
    accountLinking.trustedProviders
    to auto-link accounts only from verified OAuth providers
  • Set
    session.freshAge
    to control how recently a user must have authenticated for sensitive operations