Antigravity × Better Auth: Building a Modern TypeScript Auth Stack End-to-End
End the NextAuth fatigue with Better Auth. A practical Antigravity-driven guide that ships schema generation, OAuth, RBAC, and Passkeys with production-ready patterns.
"Do I really have to rewrite auth.ts again?" If that thought sounds familiar, you're not alone. Auth.js v5 cleaned up a lot of NextAuth's old quirks, but staring down generic type errors in [...nextauth]/route.ts is still a rite of passage I personally know too well.
Lately, a TypeScript-native, framework-agnostic, plugin-driven library called Better Auth has been quietly winning hearts in the indie and startup world.
This is the record of putting Antigravity's agent to work as a dedicated "auth engineer" and building a Better Auth stack from nothing. Rather than restating the setup steps, I've organised it around what only showed up once I had my hands on the code: the places where moving a config key one level over kills a feature without raising a single error.
Why Better Auth, and how it differs from Auth.js v5
Better Auth and Auth.js v5 enter through the same door — "user authentication library" — but their design philosophies diverge sharply. Auth.js is tightly integrated with Next.js's App Router, while Better Auth is a headless library that exposes raw HTTP handlers.
That difference shows up immediately when you scaffold with Antigravity. With Auth.js you have to keep auth.ts and route.ts in sync, and you find yourself adding "make sure both files agree" to every prompt. Better Auth pushes all the logic into one auth.ts, while the HTTP layer is just auth.handler. The cognitive load on the agent — and on you — drops noticeably.
The three things that finally tipped me over personally:
Database schema is generated declaratively — your Drizzle/Prisma schema file is emitted by a CLI command
Passkeys, magic links, and 2FA layer in cleanly via plugins — the core stays small
It runs on edge runtimes — Cloudflare Workers and Vercel Edge don't break it
You can argue that Auth.js can do all of this (a more conventional stack is covered in Building a Next.js SaaS with Antigravity: Auth, Stripe, and D1). The problem is the number of "right ways" to do it varies by every blog post you read. Better Auth is more opinionated, which makes it much easier for an Antigravity agent to internalize a single canonical pattern.
Stack assumptions and project bootstrap
The samples in this article assume Next.js 16 (App Router) + TypeScript 5.6 + Drizzle ORM + Cloudflare D1. The database adapter swap is straightforward — PostgreSQL via Neon or Prisma works the same way — so feel free to translate to whatever stack you live in.
# Bootstrap the base projectpnpm create next-app@latest my-saas --typescript --app --eslint --tailwind --src-dircd my-saas# Install auth + DB librariespnpm add better-auth drizzle-ormpnpm add -D drizzle-kit @types/bun# For Cloudflare D1pnpm add @cloudflare/workers-types better-sqlite3
When you open this project in Antigravity, the very first thing I'd do is add a line to AGENTS.md: "This project uses Better Auth. Do not propose Auth.js as an alternative." That single sentence keeps the agent's recommendations stable across long sessions.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been worn down by NextAuth/Auth.js v5 complexity, you'll get a clear set of decision points and step-by-step instructions for migrating a real project to Better Auth
✦You'll learn how to drive Antigravity agents through the entire stack — schema generation, OAuth, RBAC, Passkeys — with prompt patterns that actually hold up in production
✦You'll come away with the cookie, CSRF, session expiry, and multi-tenant isolation traps already mapped out, with the working code shown next to the broken version
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Create src/lib/auth.ts with a minimal config. We'll add plugins later, so start lean.
// src/lib/auth.tsimport { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { db } from "@/db";export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite", // use "pg" for PostgreSQL }), emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, // 8 characters is no longer good enough in 2026 }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // sliding refresh once per day cookieCache: { enabled: true, maxAge: 60 * 5, // 5-minute cookie cache }, },});export type Session = typeof auth.$Infer.Session;
Now generate the schema from the CLI. You can run this directly from Antigravity's terminal.
# Generate the schema file (Drizzle writes a schema.ts)npx auth@latest generate --output src/db/schema.ts# Create the migration filesnpx drizzle-kit generate# Apply (D1 example)npx wrangler d1 migrations apply my-saas-db --local
One note on the command itself: the CLI entry point is now npx auth@latest. Most articles you'll find online still say npx @better-auth/cli generate, which continues to work, but the documented entry point has moved to the auth package. What --output means depends on your adapter — a schema file for Drizzle, a raw SQL file for Kysely.
Now look at the expected output. The core tables are user, session, account, and verification — all singular. There is no users and no verificationTokens. If Auth.js (NextAuth) naming is still living in your head, this will trip you up; I assumed generation had failed and re-ran generate three times before reading the file properly. If you want plural names, you have to ask for them explicitly with user: { modelName: "users" }.
If account is missing entirely, you'll hit foreign-key constraint errors the moment you add OAuth providers. The classic cause is "added a plugin but forgot to re-run generate." I've hit that one twice. Make it a reflex: any time you add a plugin, re-run generate.
Step 2 — Wiring the HTTP handler into Next.js
Better Auth exposes its HTTP handler directly, so all you need in Next.js is a thin Route Handler wrapper.
// src/app/api/auth/[...all]/route.tsimport { auth } from "@/lib/auth";import { toNextJsHandler } from "better-auth/next-js";export const { POST, GET } = toNextJsHandler(auth);
That's it. The generic-soup of Auth.js v5's [...nextauth]/route.ts simply does not exist here.
I pull baseURL from NEXT_PUBLIC_APP_URL so production, staging, and local don't end up disagreeing about which domain to issue cookies for. process.env.NEXT_PUBLIC_* lands in the client bundle, so be careful never to put a secret behind that prefix.
Step 3 — Email + password with verification email
Once requireEmailVerification: true is on, you must register a hook that sends the verification email. I usually reach for Resend, so the example uses it.
This was the first thing to bite me. requireEmailVerification lives under emailAndPassword, but the sending function sendVerificationEmail belongs to a separate top-level emailVerification block. Same feature, different level. I put it inside emailAndPassword where it felt natural, got no type error and no runtime exception, and spent half a day wondering why not a single verification email ever went out.
// src/lib/auth.tsexport const auth = betterAuth({ // ... database / session config ... emailAndPassword: { enabled: true, requireEmailVerification: true, // the "require it" half lives here minPasswordLength: 12, }, emailVerification: { sendOnSignUp: true, // send right after signup sendOnSignIn: true, // re-send when an unverified user tries to log in autoSignInAfterVerification: true, // the "send it" half lives here — NOT inside emailAndPassword sendVerificationEmail: async ({ user, url }) => { // deliberately not awaited: the docs warn about timing attacks void resend.emails.send({ from: "noreply@example.com", to: user.email, subject: "Please verify your email", html: ` <p>Hi ${user.name ?? "there"},</p> <p>Click the link below to verify your email address:</p> <p><a href="${url}">${url}</a></p> <p>This link expires in 24 hours.</p> `, }); }, },});
The void is intentional. Awaiting the send makes response times differ between existing and new addresses, which leaks account existence. But on Cloudflare Workers or Vercel Functions, execution can be cut off the instant you return a response — so hand the promise to ctx.waitUntil() or your platform's equivalent. Leaving a bare void at the edge produces the worst possible symptom: emails arrive locally and silently vanish in production.
If it still never fires, what's left is almost always the provider. With Resend you cannot send outside the sandbox until SPF/DKIM are configured for the from domain.
Showing error.message directly to the user is fine here — Better Auth's error strings are intentionally written so they don't leak anything an attacker could weaponize. "Email already exists" or "Password is too short" is the level of detail you'll see.
Step 4 — Adding Google and GitHub OAuth
OAuth providers are pure config additions. When I prompt Antigravity with "enable Google and GitHub OAuth," this is essentially the diff it produces.
The client side is just signIn.social({ provider: "google" }).
<button type="button" onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard" })}> Sign in with Google</button>
The subtle but critical setting here is accountLinking. If you leave it off, a user who signs up with email/password and later signs in with Google ends up as two separate accounts — and "where did my purchase history go?" tickets follow. On the other hand, automatic linking for any provider opens an account-takeover vector through providers that don't verify email. The standard advice is trustedProviders containing only providers that verify email addresses, like Google.
Step 5 — Reading session in server components and middleware
In App Router, fetching the session on the server means reading cookies from headers. Better Auth gives you auth.api.getSession, so the boilerplate is minimal.
// src/app/dashboard/page.tsx (server component)import { auth } from "@/lib/auth";import { headers } from "next/headers";import { redirect } from "next/navigation";export default async function DashboardPage() { const session = await auth.api.getSession({ headers: await headers(), }); if (!session) redirect("/sign-in"); return ( <main> <h1>{session.user.name}'s dashboard</h1> </main> );}
For global protection, middleware is usually the answer, and middleware runs on the Edge. With cookieCache enabled, middleware only has to verify the cookie's HMAC — no DB hit per request.
You might be tempted to just call auth.api.getSession from middleware on every request. Don't. Hitting D1 or Postgres from the Edge adds real cold-start latency, and you'll start brushing against Cloudflare's 50 ms CPU limit. Use middleware for "looks logged in," and do real authorization checks inside server components. That two-tier approach holds up far better in production.
Step 6 — Role-based access control (RBAC)
For any SaaS, admin / member / viewer separation shows up sooner or later (for tenant-level isolation patterns, see Multi-tenant SaaS RBAC with Stripe Metered Billing). Better Auth ships an admin plugin that gets you most of the way there.
"Most of the way" comes with a condition. Out of the box the admin plugin only recognises two roles — admin and user. Names like member or viewer require an access-control definition. Skip that and defaultRole: "member" will be accepted while member holds no permissions at all.
Drop the s and write adminRole and TypeScript will sometimes reject the excess property and sometimes wave it through. When it waves it through, the default ["admin"] applies — so nothing breaks as long as admin is your only privileged role. Add superadmin later and it surfaces as "admin operations return 403 for no visible reason."
The client needs adminClient() registered too, or authClient.admin.* is simply undefined.
// src/lib/auth-client.tsimport { createAuthClient } from "better-auth/react";import { adminClient } from "better-auth/client/plugins";export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_APP_URL!, plugins: [adminClient()],});
After adding the plugin, you must re-runnpx auth@latest generate. A role column is added to user, and you'll need to push that migration as well.
Permission checks on the server look like this.
// src/app/admin/page.tsximport { auth } from "@/lib/auth";import { headers } from "next/headers";import { redirect } from "next/navigation";export default async function AdminPage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/sign-in"); if (session.user.role !== "admin") redirect("/dashboard"); // not allowed return <h1>Admin dashboard</h1>;}
If you're hitting permissions from API routes, prefer Better Auth's auth.api.userHasPermission. Direct string comparison on role works at first, but the moment your role hierarchy grows, it ages badly. Standardize on userHasPermission early.
Step 7 — Production-grade Passkeys and 2FA
Passkeys (WebAuthn) and TOTP-based 2FA both come as plugins — but Passkey is not bundled with better-auth itself. It ships as its own package, which is why importing from better-auth/plugins/passkey fails to resolve.
npm install @better-auth/passkey
// src/lib/auth.tsimport { passkey } from "@better-auth/passkey"; // its own package, not the coreimport { twoFactor } from "better-auth/plugins";export const auth = betterAuth({ plugins: [ passkey({ rpID: "example.com", rpName: "My SaaS", origin: process.env.NEXT_PUBLIC_APP_URL!, }), twoFactor({ issuer: "My SaaS", // 10 backup codes, 8 chars each backupCodes: { amount: 10, length: 8 }, }), ],});
The server half alone won't make the button work. Every Better Auth plugin has a client counterpart, and forgetting it is the single easiest mistake to make here.
// src/lib/auth-client.tsimport { createAuthClient } from "better-auth/react";import { passkeyClient } from "@better-auth/passkey/client";import { twoFactorClient, adminClient } from "better-auth/client/plugins";export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_APP_URL!, plugins: [passkeyClient(), twoFactorClient(), adminClient()],});
With that in place, the registration button is just this.
import { authClient } from "@/lib/auth-client";export function RegisterPasskeyButton() { const handleRegister = async () => { const { error } = await authClient.passkey.addPasskey(); if (error) alert(`Registration failed: ${error.message}`); else alert("Passkey registered for this device"); }; return <button onClick={handleRegister}>Register this device as a Passkey</button>;}
Skip the client plugin and authClient.passkey is undefined, so pressing the button throws a bare Cannot read properties of undefined. It reads like a React bug rather than an auth misconfiguration, which is exactly why it costs so much time.
The Passkey trap that catches everyone: rpID versus origin mismatch. rpID must be the bare domain (example.com), origin must include the scheme (https://example.com). If you use subdomains, set rpID to the parent domain or your Passkey won't sync across subdomains. I lost 30 minutes to this on day one.
Picking the right database adapter for your scale
Better Auth ships official adapters for Drizzle, Prisma, and Kysely, plus drivers for SQLite (D1, libSQL, Bun SQLite), PostgreSQL (Neon, Supabase, RDS), and MySQL/PlanetScale. Choosing the right one early matters because the schema generator behaves slightly differently per provider, and migrating between them later is non-trivial.
The pattern that has held up best across my own projects as an indie developer: start with D1 + Drizzle for prototypes (free tier is generous, edge-native), and migrate to Neon Postgres + Drizzle once you cross 100 paying users or need stricter consistency. The reason is mainly cost predictability — D1's row-read pricing can surprise you when sessions get hot, while Neon's compute auto-scaling tracks demand smoother.
Here's what changes per adapter at the schema level.
// SQLite (D1, libSQL): integer-based timestampsdatabase: drizzleAdapter(db, { provider: "sqlite" })// Generated columns use INTEGER for createdAt/updatedAt (unix ms)// PostgreSQL (Neon, Supabase): native timestamp with timezonedatabase: drizzleAdapter(db, { provider: "pg" })// Generated columns use TIMESTAMP WITH TIME ZONE// MySQL (PlanetScale): no foreign keys by defaultdatabase: drizzleAdapter(db, { provider: "mysql" })// You need to set generateSchema: { mysql: { foreignKeys: false } }
The MySQL/PlanetScale gotcha bites teams hardest. PlanetScale famously disallows foreign keys, so the generator has to omit them. If you forget the foreignKeys: false flag, your migration will validate locally against MySQL but fail on PlanetScale's branch deploy. The workaround is to enforce the relationships in your application layer instead — auth.api.getSession returns the joined session+user object, so most call sites won't notice the missing FK.
For Prisma users, schema generation works through prisma db push rather than Drizzle's CLI flow. That changes the agent prompt I use: instead of "regenerate schema after adding plugin," I tell Antigravity to "regenerate schema, run prisma db push, then commit the migration." The agent occasionally forgets the second step on its own, so make it explicit in AGENTS.md.
Migration checklist: moving from Auth.js v5 to Better Auth
If you're not greenfielding, this is the part that matters. Migrating an existing Auth.js v5 project to Better Auth is mostly mechanical, but there are five irreversible decisions you should make consciously before you start.
Decision 1 — Will users have to sign in again?
Auth.js v5 issues JWT or database sessions; Better Auth uses its own cookie format. There's no clean session-format compatibility layer. The honest answer is "yes, all users sign in again on cutover." Plan a maintenance window, communicate it ahead of time, and expect a 1–2 day support bump from confused users.
Decision 2 — Are you keeping the same users table?
The Auth.js users table and Better Auth's users table differ in subtle ways: Better Auth requires emailVerified as a boolean, while Auth.js stored it as a date. If you have downstream queries that depend on the date semantics, you'll need a one-time migration script that maps emailVerified IS NOT NULL → true.
// migration/auth-js-to-better-auth.tsimport { db } from "@/db";import { users } from "@/db/schema";import { sql } from "drizzle-orm";await db.execute(sql` ALTER TABLE users ADD COLUMN email_verified_bool BOOLEAN NOT NULL DEFAULT FALSE`);await db.execute(sql` UPDATE users SET email_verified_bool = (email_verified IS NOT NULL)`);await db.execute(sql` ALTER TABLE users DROP COLUMN email_verified; ALTER TABLE users RENAME COLUMN email_verified_bool TO email_verified;`);
I recommend running this migration in three steps (add new column, backfill, drop old column) rather than one combined ALTER, so you can verify each step on a snapshot before applying to production.
Decision 3 — How are you handling OAuth account linking?
Auth.js v5 and Better Auth implement account linking differently. In Auth.js, the accounts table tied OAuth identities to users via userId. Better Auth uses the same shape but stores the providerAccountId differently for some providers (notably Google's sub vs id). If you have existing OAuth users, plan a one-time backfill script that walks your Auth.js accounts table and re-keys the rows for Better Auth.
Decision 4 — Where does session data live?
Auth.js JWT sessions store everything in the cookie. Better Auth sessions live in the database with only an opaque session ID in the cookie. This is generally a security improvement (you can revoke sessions server-side), but it does mean an extra DB round trip per login. The cookieCache setting we showed in Step 1 brings that overhead back down to near-zero for routine reads.
Decision 5 — Are you ready to rip out custom callbacks?
Auth.js's callbacks.session and callbacks.jwt are how most teams add custom claims. Better Auth replaces these with additionalFields and explicit hooks. The mental model shift is "describe the shape, then write the hook" rather than "write the callback that mutates the shape." The migration is easy, but the muscle memory takes a week.
A complete migration in a real SaaS project of mine took about 8 hours of focused work for ~3,000 lines of auth-related code. Most of that time was on testing the OAuth flow against real Google/GitHub accounts, not on the rewrite itself. Budget your migration day for testing, not coding.
Observability: knowing when auth breaks before users tell you
Production auth fails in quiet ways. A user gets a 500 on signup; the page reloads; they leave. You'll never see it in your error tracker because the error happened in an edge function 12 layers deep. Better Auth provides hooks that let you wire in structured logging early, and I strongly recommend doing it before you launch.
One more level-of-nesting story. Plugin-authored hooks take an array of { matcher, handler } objects — but hooks.before / hooks.after passed directly to betterAuth() take exactly one createAuthMiddleware(), not an array. To cover multiple endpoints you branch on ctx.path inside the single middleware. I brought the plugin-style shape over by habit and sat looking at an empty log stream for half an hour.
// src/lib/auth.ts (add hooks)import { betterAuth } from "better-auth";import { createAuthMiddleware } from "better-auth/api";import { logger } from "@/lib/logger"; // your structured logger of choiceexport const auth = betterAuth({ // ... existing config ... hooks: { // one createAuthMiddleware, not an array — branch inside after: createAuthMiddleware(async (ctx) => { if (!ctx.path.startsWith("/sign-in") && !ctx.path.startsWith("/sign-up")) return; // newSession is only populated when auth actually succeeded const newSession = ctx.context.newSession; logger.info("auth.attempt", { path: ctx.path, userId: newSession?.user.id, success: Boolean(newSession), method: ctx.path.includes("email") ? "email" : "social", ip: ctx.headers?.get("cf-connecting-ip"), userAgent: ctx.headers?.get("user-agent"), }); }), },});
Deriving success from the presence of ctx.context.newSession is the part worth copying. You don't need to inspect response headers, and header-name-based checks break silently across library updates. ctx.request can be undefined depending on your adapter, so read headers from ctx.headers.
The four signals worth logging from day one: signup attempts (success/fail), signin attempts (success/fail), password resets, and OAuth provider errors. With those four streams, you can build a single dashboard in Grafana, Axiom, or whatever you use that tells you "auth is healthy" at a glance. The day a Google OAuth credential rotation accidentally invalidates your client secret, this dashboard will tell you in 30 seconds instead of 30 minutes.
Don't log password values, even hashed, even by accident. Better Auth never exposes them to hooks, but if you write your own custom plugin, double-check that your console.log(context) call doesn't accidentally serialize a request body containing the field.
For Cloudflare Workers specifically, I push these structured logs to Axiom via their HTTP API rather than console.log, because Workers' built-in log retention is too short to catch slow-burn issues like "OAuth callback fails 1% of the time when traffic spikes."
Where Antigravity earns its keep — three real prompts I use
Throughout this guide I've assumed you're driving Antigravity's agent through these changes. Let me give you the three concrete prompts I keep in a snippet file, so you can paste them as starting points.
Prompt 1 — Initial scaffold from a fresh Next.js project:
Set up Better Auth in this project. Use Drizzle ORM with the existing D1 database in src/db/index.ts. Email + password with verification required, 12-character minimum password. Add Google and GitHub social providers, with account linking enabled only for Google. Generate the schema, create the migration files, and update AGENTS.md with a section on how Better Auth is configured here.
Prompt 2 — Adding RBAC to an existing setup:
Add the Better Auth admin plugin. Define three roles — admin, member, viewer — using createAccessControl from better-auth/plugins/access, and pass ac and roles into admin() (the option is adminRoles, plural; default new users to "member"). Register adminClient() on the client as well. Regenerate the schema and create a migration. Then create a server-only requireRole helper in src/lib/auth-helpers.ts that wraps auth.api.getSession and throws if the role doesn't match. Use it to protect /admin routes.
Prompt 3 — Migrating from Auth.js v5:
Audit the current Auth.js v5 setup in src/lib/auth.ts and src/app/api/auth/[...nextauth]/route.ts. Produce a migration plan in markdown that lists: (1) data model changes needed, (2) provider-by-provider migration steps for Google and GitHub, (3) which custom callbacks need to become additionalFields or hooks, (4) estimated effort in hours. Do not write any code yet — I want the plan first.
Prompt 3 is the most valuable one. As I mentioned earlier, the highest information density of a migration is in the planning phase, and Antigravity's agent is excellent at it precisely because the work is mostly reading and structuring rather than writing. I always run prompt 3 before I touch any code.
A 60-second smoke test that proves your config took effect
Everything I've flagged so far shares one property: none of it throws. No type error, no stack trace. The server boots, the pages render, and a feature just quietly doesn't work. Auth is the very first thing a user touches, which makes that particular failure mode expensive.
So I keep a short smoke test and run it any day I've touched auth.ts. No browser involved — it only answers the question "did the config actually take effect?"
#!/usr/bin/env bash# scripts/auth-smoke.sh — run after every auth.ts changeset -euo pipefailBASE="${1:-http://localhost:3000}"EMAIL="smoke+$(date +%s)@example.com"echo "== 1. handler is mounted =="curl -sf "$BASE/api/auth/ok" > /dev/null && echo " handler responds"echo "== 2. sign-up + verification dispatch =="RES=$(curl -s -X POST "$BASE/api/auth/sign-up/email" \ -H "content-type: application/json" \ -d "{\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery\",\"name\":\"Smoke\"}")echo "$RES" | head -c 200; echoecho "== 3. unverified sign-in must be rejected (expect 403) =="CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/api/auth/sign-in/email" \ -H "content-type: application/json" \ -d "{\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery\"}")[ "$CODE" = "403" ] && echo " requireEmailVerification is live" \ || echo " WARNING $CODE — requireEmailVerification is not taking effect"
Step 3 is the one that earns its keep. A 200 there means requireEmailVerification isn't being read, or the emailVerification block is in the wrong place. You get that answer without opening a mailbox, which matters when your provider is still in sandbox mode.
For the schema side, just read the generated file. Checking singular versus plural takes a few seconds.
And here's the symptom-to-cause lookup for everything above, so you can work backwards from what you're seeing.
Symptom
Suspect
Correct shape
No verification email ever sends, no error
Where sendVerificationEmail lives
Directly under emailVerification, not emailAndPassword
Emails arrive locally, never in production
Edge runtime cutting off the async send
Hand the promise to waitUntil instead of leaving a bare void
No users table in the generated schema
Auth.js naming assumptions
Core tables are user / session / account / verification
Custom roles hold no permissions
No access control defined
Pass ac and roles built with createAccessControl
Admin operations return 403 only for new roles
Written as adminRole
The option is adminRoles, plural
authClient.passkey is undefined
Client plugin not registered
Add passkeyClient() to createAuthClient
Auth logs are completely empty
An array passed to hooks.after
One createAuthMiddleware(), branching on ctx.path
This table matters more, not less, when an agent is writing the code. Antigravity is very good at producing something that looks right and runs — so the thing a human has to hold onto isn't the code that fails, it's the code that's quietly wrong. I keep the table in AGENTS.md and point the agent at it whenever auth is in scope.
Five production traps and how to avoid them
After running Better Auth in four real projects, here are the production traps I keep seeing. An Antigravity agent on its own will produce code that "works" but isn't safe against these — give them a human review.
Trap 1 — Forgetting to wire secret from environment
Without an explicit betterAuth({ secret: process.env.BETTER_AUTH_SECRET }), Better Auth falls back to an auto-generated dev key, which then ships to production and invalidates every session on each deploy. Generate 32 bytes via openssl rand -hex 32, put it in BETTER_AUTH_SECRET, and pass it explicitly.
Trap 2 — Edge Runtime without cookieCache enabled
If you're using middleware or edge functions, treat cookieCache.enabled: true as effectively required. Without it, every request hits the DB and Cloudflare Workers' 50 ms CPU limit gets uncomfortable fast.
Trap 3 — Multi-tenant SaaS without organizationId in session
For tenant isolation, either use Better Auth's organization plugin or roll your own additionalFields to put organizationId in the session. Skip this and the same user can carry tenant A's session into tenant B's data — a critical isolation bug.
Trap 4 — Stale React Query / SWR cache after signOut
Client-side caches that survive a logout will show "the last user's data for a frame" to the next user. After a successful signOut, explicitly clear caches (queryClient.clear(), etc.) — make this a habit, not an afterthought.
Trap 5 — Forgetting migrations on production
Add a plugin → regenerate schema → works locally → forget to run migrations on prod → 500 errors. Wire drizzle-kit migrate into your deploy CI so local, preview, and production are guaranteed to be at the same schema version. Note that the programmatic getMigrations() path is not available to Drizzle or Prisma users — it only supports the built-in Kysely adapter — so your ORM's migration tool has to be in the deploy path, not a manual step.
Real-world performance: what to expect under load
Numbers from a load test I ran on a hobby SaaS deployed to Cloudflare Workers + D1 at the moment of writing. Test traffic: 1,000 concurrent users hitting /dashboard with a valid session cookie. Test duration: 5 minutes.
Without cookieCache: p50 latency 87 ms, p99 latency 412 ms. D1 read load was the bottleneck.
With cookieCache (5-minute TTL): p50 latency 14 ms, p99 latency 89 ms. CPU time per request dropped from 28 ms to 4 ms.
With cookieCache + middleware-only HMAC check: p50 latency 8 ms, p99 latency 41 ms. The DB was idle except on cache misses.
The takeaway: middleware that does only HMAC verification is roughly 10x faster than middleware that calls getSession. For 99% of requests, you simply don't need the database. Use getSession in the actual page or API route where you need full session data, not in middleware.
The same load test on Auth.js v5 (JWT mode) ran at p50 22 ms, p99 124 ms — better than uncached Better Auth, worse than cached Better Auth. JWT sessions don't pay the DB roundtrip, but you give up server-side revocation in exchange. The Better Auth + cookieCache combination is genuinely the best of both worlds in my testing.
Memory usage in the Worker bundle: Auth.js v5 + adapter ≈ 1.8 MB, Better Auth + Drizzle adapter ≈ 0.9 MB. That matters because Cloudflare's hard 10 MB Worker limit keeps creeping closer once you add observability, Stripe, AI integrations, and so on. Halving auth's footprint buys you headroom for the things that actually drive revenue.
Wrapping up — your concrete next step
We've covered Better Auth from "first install" all the way through production landmines. It's a wide surface; you don't have to adopt all of it tomorrow.
The single most useful next step: open your project's existing auth.ts, hand it to Antigravity's agent, and ask for an estimate of what migrating to Better Auth would change. Don't ask for the migration itself yet — ask for the diff size. That alone tells you, in your project's specific terms, how many lines you'd lose, what new config you'd add, and whether the trade is worth it. The most information-dense moment of an Auth.js → Better Auth migration is the estimation phase, before any code changes — and the agent is genuinely good at exactly that. Try that step first, and decide from there.
Authentication sits on the safety boundary of your product. Don't stop at "it works" — walk through the five traps above, one at a time, against your own stack, and grow the auth layer with intent. The reason I switched is that Better Auth is, in a phrase, a foundation that grows with you. That's the part I'd want every reader to feel for themselves.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.