Better Auth

28. March, 2025 11 min read Develop

Authentication you can read

Every project reaches the same fork. You either hand authentication to a service and accept that your users live in someone else's database, or you build it yourself and spend the next three weeks discovering what you forgot about session rotation. Neither option has ever felt right to me, which is why Better Auth caught my attention.

It is a TypeScript library, not a service. It writes its tables into your database, runs inside your app, and hands you a Response when a request comes in. There is no dashboard, no per-monthly-active-user pricing, and no third party holding your session table hostage.

I was already using Prisma on the project I tried it on, which I wrote about in Using Prisma with Next.js, so the adapter story mattered to me more than the feature list. Version 1.2.5 is current as I write this, and 1.2 landed a few weeks ago with a pile of new plugins, so this is a reasonable moment to look at it.

Where it actually sits

The comparison people reach for is Auth.js, and it’s the wrong one to start with. Auth.js is built around OAuth providers, with credentials support bolted on and deliberately discouraged. Better Auth treats email and password as a first-class citizen and hangs the social providers off the side.

The comparison that matters is Clerk or Auth0. Those give you hosted UI, a support contract and someone else’s uptime. Better Auth gives you none of that and, in exchange, your user table is a table in your database that you can join against. For a small product where the user record needs to be foreign-keyed to half the schema, that trade is worth making. For a team without anyone who wants to own auth, it is not, and I’d stop pretending otherwise.

Setting it up

npm install better-auth

Two environment variables. The secret is used for encryption and hashing, so generate a real one:

# .env
BETTER_AUTH_SECRET=  # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000

Then an auth.ts, exported as auth so the CLI can find it later:

// lib/auth.ts
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export const auth = betterAuth({
  appName: 'My App',
  database: prismaAdapter(prisma, {
    provider: 'postgresql',
  }),
  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
  trustedOrigins: ['http://localhost:3000'],
});

The as string casts are ugly and I resented typing them, but process.env.X is string | undefined and the option is not. Validate your environment with Zod at boot and the casts become honest.

If you’re not on an ORM, you can hand database a better-sqlite3 instance, a pg Pool, a mysql2 pool, or any Kysely dialect, and Better Auth runs the queries itself. The built-in adapters are Prisma, Drizzle and MongoDB. There is a memory adapter too, which is mostly useful for tests. That’s the whole list, and it’s worth knowing before you start, because a fair amount of what you’ll read online invents adapters that don’t exist.

The schema is yours, which is the point

Better Auth needs four core tables, and plugins add more:

  • user holds the profile plus an emailVerified flag
  • session holds live sessions, with IP address and user agent
  • account holds linked providers, OAuth tokens, and the password hash
  • verification holds short-lived tokens for email confirmation and password resets

The password living on account rather than on user threw me for a minute, and then made sense: email-and-password is just another provider, so it gets a row like GitHub does. Which also means a user can have several accounts and one of them happens to be credentials.

npx @better-auth/cli generate   # writes a Prisma/Drizzle schema or SQL file
npx @better-auth/cli migrate    # applies directly (built-in Kysely adapter only)

With Prisma you run generate and then your usual prisma migrate dev. The important habit is re-running generate every time you add a plugin or an additional field, because plugins ship schema. Forgetting that produces runtime errors about missing columns that read like library bugs and aren’t.

Additional fields go in the config rather than straight into the schema file, so the types flow through to useSession and signUp.email:

export const auth = betterAuth({
  // ...
  user: {
    additionalFields: {
      role: {
        type: 'string',
        defaultValue: 'user',
        input: false, // users don't get to set their own role
      },
    },
  },
});

That input: false is doing real work. Without it, the sign-up endpoint accepts a role from the request body and you have handed anyone with curl an admin account.

Mounting the handler

auth.handler takes a web Request and returns a Response. Every framework integration is a thin wrapper over that.

Next.js App Router:

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

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

Express, where there is a trap:

// server.ts
import express from 'express';
import { toNodeHandler } from 'better-auth/node';
import { auth } from './auth';

const app = express();

app.all('/api/auth/*', toNodeHandler(auth));

// after, never before
app.use(express.json());

app.listen(3005);

Mount express.json() before the auth handler and the body gets consumed by the JSON parser, the handler waits for a stream that will never arrive, and every client call hangs on pending forever. There’s no error, just silence. On Express 5 the route pattern changes to /api/auth/*splat, and CommonJS is not supported at all, so "type": "module" in your package.json.

You can check the mount with a GET to /api/auth/ok.

The client

// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
  baseURL: 'http://localhost:3000',
});

There are equivalents for Vue, Svelte, Solid and vanilla. The React one gives you useSession as a reactive hook, and everything else is a plain async call:

await authClient.signUp.email(
  { email, password, name },
  {
    onSuccess: () => router.push('/dashboard'),
    onError: (ctx) => setError(ctx.error.message),
  }
);

await authClient.signIn.social({ provider: 'github' });

The second argument is a set of lifecycle callbacks (onRequest, onSuccess, onError), which I ended up preferring to unwrapping { data, error } at every call site. Users are signed in automatically after sign-up unless you set autoSignIn: false.

On the server, the same endpoints are available under auth.api, taking body, headers and query instead of a merged object:

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

const session = await auth.api.getSession({
  headers: await headers(),
});

Failures throw APIError from better-auth/api, which carries a status, so mapping it to an HTTP response is straightforward.

Sending the mail is your job

Better Auth never sends an email. It calls a function you supply. I like that more than I expected to, because it means the verification mail lives with the rest of the templates instead of in someone’s dashboard. I’d been taking React Email apart the month before, so it dropped straight in:

// lib/auth.ts
import { sendPasswordResetMail, sendVerificationMail } from '@/lib/mailer';

export const auth = betterAuth({
  // ...
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      await sendPasswordResetMail(user.email, url);
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url }) => {
      await sendVerificationMail(user.email, user.name, url);
    },
  },
});

Keep the rendering behind that mailer module rather than inlining it here, because the templates are JSX and auth.ts would have to become auth.tsx to hold them. Small thing, annoying to discover halfway through.

With requireEmailVerification: true, an unverified user attempting to sign in triggers sendVerificationEmail again rather than getting a session, which is the behaviour you want and not the behaviour I assumed.

Sessions are worth a deliberate look too. They last seven days by default and get extended whenever they’re used and updateAge has passed:

session: {
  expiresIn: 60 * 60 * 24 * 7,
  updateAge: 60 * 60 * 24,
  cookieCache: {
    enabled: true,
    maxAge: 5 * 60,
  },
},

cookieCache keeps a signed copy of the session in the cookie so getSession can answer without a database round trip. With a five minute window, a revoked session stays usable for up to five minutes, so it’s a straight trade between latency and immediacy. There’s also freshAge, one day by default, controlling how recently a session must have been created before the sensitive endpoints will accept it. You can set it to 0 to disable the check. I would think about that one for a while first.

Plugins are where it gets interesting

The core is deliberately small. Everything else is a plugin, and most plugins come in pairs: a server half and a client half that adds the matching methods to authClient.

Two-factor authentication, as an example:

// lib/auth.ts
import { twoFactor } from 'better-auth/plugins';

export const auth = betterAuth({
  appName: 'My App', // used as the TOTP issuer
  // ...
  plugins: [twoFactor()],
});
// lib/auth-client.ts
import { twoFactorClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  plugins: [twoFactorClient()],
});

Then, after re-running the CLI:

const { data } = await authClient.twoFactor.enable({ password });
// data.totpURI -> render as a QR code
// data.backupCodes -> show once, never again

await authClient.twoFactor.verifyTotp({ code });

Worth knowing: twoFactorEnabled stays false until the user verifies a TOTP code. The flag reflects a working authenticator, not an intention, which is exactly right and caught me off guard the first time. After that, signIn.email for a 2FA user returns twoFactorRedirect: true instead of a session, and you handle the second step yourself.

The organisation plugin covers multi-tenancy: organisations, members, invitations, and since 1.2, teams inside an organisation. Role-based access control lives in better-auth/plugins/access:

import { createAccessControl } from 'better-auth/plugins/access';
import { defaultStatements, adminAc } from 'better-auth/plugins/organization/access';

const statement = {
  ...defaultStatements,
  invoice: ['read', 'issue', 'void'],
} as const;

const ac = createAccessControl(statement);

const admin = ac.newRole({
  ...adminAc.statements,
  invoice: ['read', 'issue', 'void'],
});

Spreading defaultStatements and adminAc.statements is not optional. Define a custom role without them and you silently overwrite the built-in permissions for that role, which is a fun afternoon.

The rest of 1.2 brought an API key plugin, a captcha plugin, a Stripe plugin for subscriptions, JWT encryption and a few more social providers. The passkey, magic link, email OTP, username, admin, multi-session and OIDC provider plugins were already there. It’s a lot of surface for a library that reached 1.0 four months ago, and I’d treat the newer ones with the caution that implies.

Testing

The nice consequence of auth being a library is that it is testable without a network. Point it at the memory adapter and call the server API directly:

// auth.test.ts
import { expect, test } from 'vitest';
import { betterAuth } from 'better-auth';
import { memoryAdapter } from 'better-auth/adapters/memory';
import { APIError } from 'better-auth/api';

const makeAuth = () =>
  betterAuth({
    baseURL: 'http://localhost:3000',
    secret: 'test-secret-not-for-production',
    database: memoryAdapter({
      user: [],
      session: [],
      account: [],
      verification: [],
    }),
    emailAndPassword: { enabled: true },
  });

test('signs a user up and returns a session', async () => {
  const auth = makeAuth();

  const result = await auth.api.signUpEmail({
    body: {
      email: 'user@example.com',
      password: 'correct-horse-battery',
      name: 'Test User',
    },
  });

  expect(result.user.email).toBe('user@example.com');
});

test('rejects a short password', async () => {
  const auth = makeAuth();

  await expect(
    auth.api.signUpEmail({
      body: { email: 'a@example.com', password: 'short', name: 'A' },
    })
  ).rejects.toBeInstanceOf(APIError);
});

A fresh instance per test keeps the in-memory store from leaking between them. The minimum password length is eight characters by default, which is what the second test is leaning on.

For anything involving cookies you’ll want a real adapter and asResponse: true, which gives you the full Response including Set-Cookie rather than just the data.

What I’d watch out for

trustedOrigins is not decoration. Get it wrong across subdomains and you’ll spend an evening staring at requests that succeed and sessions that never appear, because the cookie was set on the wrong host.

Plugin order in the array matters more than the docs suggest, particularly when several plugins hook the same endpoint.

And the schema is generated, not owned. Custom columns you add by hand outside additionalFields will not survive the next generate. Put them in the config or in a separate table.

Would I use it again

Yes, on a project where the user record is genuinely part of the domain model and I want it in the same transaction as everything else. I would not reach for it on something where auth is a checkbox and nobody on the team wants to think about session rotation. That’s what the hosted services are for, and they’re good at it.

I’m now curious about the Stripe plugin from 1.2, mostly because subscription state and user state ending up in two different databases is a problem I’ve watched several projects lose to. That might be the next thing I take apart.

‘Till next time!