Better Auth, authentication on your own server
Better Auth is a TypeScript authentication library that runs inside your process and stores accounts in your own database. The current version is 1.7.1, the licence is MIT, and the better-auth/better-auth repository holds roughly 29.6 thousand stars. There is no per-user fee here, but maintenance and security stay on your side.
How it differs from a hosted service
Services such as Clerk or Auth0 take over the entire identity layer. The user lands on their domain or their component, they issue the token, they hold the account table, and your application receives a finished identifier. The bill grows with the number of active users, because that is the billing model across this category of tools.
Better Auth draws that boundary in the opposite place. You install a dependency, mount one handler under the /api/auth/* path, and hand it a database connection. From that point the user, session, account and verification tables sit next to your own tables, and passwords along with session tokens pass only through your process. The only outbound requests are the ones you initiate yourself toward a social login provider.
That arrangement has two sides and both need looking at before a decision. On the gain side you get full ownership of the data, no fee that scales with account count, and the ability to add a column to the user table without asking anyone for permission. Queries joining an account to the rest of your domain model are ordinary database joins rather than calls to a vendor interface with a request quota.
On the cost side sits what is easy to forget at prototype stage. Security updates are yours to apply, and within a sensible window. Monitoring sign-in attempts, rotating secrets, handling reports of a compromised account, satisfying an enterprise customer's auditor, all of that moves to your team. A hosted vendor gives you a security audit report you can simply pass along. With your own installation there is nothing to pass along.
The project's scale is respectable for a library that started in May 2024. The npm registry reports roughly 5.9 million downloads in the week ending 19 August 2026, the repository is not archived, and the latest commit is dated the day this text was written. Open issues number around six hundred and sixty, which at this pace of development says that the to-do list grows as fast as the library itself.
Installation, version and licence
The licence situation is simple here, though in this category of libraries simple is not the norm. I checked three sources and all of them say the same thing. The LICENSE.md file in the repository carries the MIT text with a copyright notice for Bereket Engida from 2024. The license field in the package metadata on the npm registry reads MIT. The published package for version 1.7.1, downloaded and read from the inside, carries the same MIT text. The GitHub interface also reports MIT. There is no divergence of the kind that can surprise you during a dependency audit elsewhere.
Installation itself is one command, after which you still need a secret and a base address for the application.
npm install better-auth
# generate a secret with the command line tool
npx @better-auth/cli@latest secret
# minimal .env file
# BETTER_AUTH_SECRET=generated_value
# BETTER_AUTH_URL=http://localhost:3000The environment variables the library reads on its own are BETTER_AUTH_SECRET, BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS. There is also BETTER_AUTH_SECRETS, meant for rotation, where you supply a comma separated list of entries in the format <version>:<secret>. The first entry is current, the rest exist to read old signatures, so a rotation does not invalidate every session at once.
If you leave the secret unset, the library falls back to a default value in development, while in production it throws with an unambiguous message. That is good behaviour, but it has a side effect: sessions created locally will not carry over to production, because they were signed with a different key. A warning about a short secret appears below thirty two characters.
Server configuration usually lives in an auth.ts file and amounts to a single function call.
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from './db'
import * as schema from './db/schema'
export const auth = betterAuth({
appName: 'my-application',
baseURL: process.env.BETTER_AUTH_URL,
basePath: '/api/auth',
secret: process.env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: 'pg',
schema,
usePlural: false,
camelCase: true
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 128,
requireEmailVerification: true,
autoSignIn: true,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
await sendMail(user.email, url)
}
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!
}
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 60 * 60 * 24,
cookieCache: { enabled: true, maxAge: 5 * 60 }
},
trustedOrigins: ['https://my-application.com'],
telemetry: { enabled: false }
})The values shown above for the session section are also the defaults, so you can omit them. A session lives seven days, refreshes no more than once a day, and counts as fresh for twenty four hours after sign-in, which matters for sensitive operations such as account deletion. The session cache in a cookie defaults to five minutes and stays off until you turn it on.
Peer dependencies are marked optional across the board, so your package manager will not force anything unnecessary on you. The list includes next, react, vue, svelte, solid-js, @sveltejs/kit, @tanstack/react-start, plus the database drivers pg, mysql2, mongodb and better-sqlite3. You pick only what you actually use.
Database schema and the command line tool
The core of the library needs four tables. user holds identity, session holds active sessions, account holds links to external providers along with the password, and verification holds one-time tokens for address verification and password resets. A fifth table, rateLimit, appears only once you switch request limiting to database storage.
Plugins add tables of their own. Two-factor login adds twoFactor with the fields secret, backupCodes, verified, failedVerificationCount and lockedUntil. The organization plugin adds organization, member, invitation, plus team and teamMember when teams are enabled, and organizationRole on top of that when dynamic roles are in play.
You do not write the schema by hand. A separate command line tool generates it, published as the @better-auth/cli package, today at version 1.4.21 and also under MIT.
# generate the schema file from the configuration in auth.ts
npx @better-auth/cli@latest generate --config ./src/auth.ts --output ./src/db/schema.ts
# apply changes to the database, built-in Kysely adapter only
npx @better-auth/cli@latest migrate --config ./src/auth.ts
# interactively add the library to an existing project
npx @better-auth/cli@latest init --skip-db --package-manager pnpm
# print the detected configuration, useful when diagnosing
npx @better-auth/cli@latest infoThere is a trap here worth knowing in advance. The migrate command works only with the built-in Kysely adapter. With Prisma and with Drizzle the tool stops and prints a message saying the schema must be produced with generate and then applied through that tool's own migration mechanism. This is neither a bug nor an oversight but a deliberate decision: both of those libraries keep their own migration history, and reaching into it from outside would end in divergent state.
The available adapters are Drizzle, Prisma, MongoDB, Kysely and an in-memory one for tests. The Drizzle adapter takes a database instance and a configuration object with a provider field accepting pg, mysql or sqlite, followed by schema, usePlural, camelCase, schemaName, transaction and debugLogs. If your tables are named in the plural or live in a separate database schema, you set that here instead of editing the generated file.
The user, session, account and verification sections in the configuration let you add columns of your own to those tables and rename fields to match whatever convention the rest of your database follows. This is the difference against a hosted service that you only come to appreciate a few months into a product. A customer number from the accounting system, notification settings, a terms acceptance flag, all of it can sit in the same table as the email address, and a query joining that data is an ordinary database query. In the hosted variant you usually keep a second profile table on your side and maintain synchronisation with the vendor account, and every divergence between those two sources of truth is a bug somebody will eventually have to reproduce and fix.
Client, session and the Next.js integration
On the browser side you create a client with createAuthClient. You choose the import according to your framework, because the library publishes separate variants under better-auth/react, better-auth/vue, better-auth/svelte, better-auth/solid and better-auth/client for code using none of them.
// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL
})
// src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/auth'
export const { GET, POST } = toNextJsHandler(auth)
// client component
export function Profile() {
const { data, isPending, error, refetch } = authClient.useSession()
if (isPending) return <p>Loading</p>
if (error) return <p>{error.message}</p>
if (!data) return <button onClick={() => authClient.signIn.social({ provider: 'github' })}>Sign in</button>
return (
<div>
<span>{data.user.email}</span>
<button onClick={() => authClient.signOut().then(() => refetch())}>Sign out</button>
</div>
)
}Client methods mirror endpoint paths. The /sign-in/email path maps to authClient.signIn.email, /sign-up/email maps to authClient.signUp.email, and /revoke-sessions maps to authClient.revokeSessions. The useSession hook returns an object with the fields data, isPending, isRefetching, error and a refetch function.
On the server you read the session through auth.api.getSession, passing the request headers. That endpoint requires headers explicitly and accepts two query parameters, disableCookieCache and disableRefresh, useful when you need state straight from the database rather than from the cookie cache.
In Next.js you mount the handler with toNextJsHandler, which returns functions for the GET, POST, PATCH, PUT and DELETE methods. To the plugins array in the server configuration you add nextCookies(), and it has to be the last entry in that array, because the plugin writes cookies in a hook that runs after the others.
Plugins: two-factor login and organizations
The plugin system is what separates this library from a thin wrapper around a session. The package for version 1.7.1 contains twenty six plugin directories. Beyond the ones described below there are, among others, admin, magic-link, email-otp, phone-number, username, anonymous, multi-session, bearer, jwt, generic-oauth, captcha, device-authorization, one-time-token, haveibeenpwned and open-api. Built-in social login providers number thirty five, from Google and GitHub to Kakao, Naver, Linear and Vercel.
A plugin is simultaneously a set of endpoints, a fragment of database schema and a type extension. You add it in two places at once, on the server and on the client, otherwise type inference stops working.
// server
import { betterAuth } from 'better-auth'
import { twoFactor, organization } from 'better-auth/plugins'
import { createAccessControl } from 'better-auth/plugins/access'
const ac = createAccessControl({
invoice: ['read', 'issue', 'cancel'],
project: ['read', 'edit', 'delete']
})
const accountant = ac.newRole({ invoice: ['read', 'issue'] })
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'my-application',
skipVerificationOnEnable: false,
trustDeviceMaxAge: 60 * 60 * 24 * 30,
accountLockout: {
enabled: true,
maxFailedAttempts: 5,
durationSeconds: 900
}
}),
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5,
creatorRole: 'owner',
membershipLimit: 100,
invitationExpiresIn: 60 * 60 * 48,
cancelPendingInvitationsOnReInvite: true,
requireEmailVerificationOnInvitation: true,
ac,
roles: { accountant },
teams: { enabled: true, allowRemovingAllTeams: false },
sendInvitationEmail: async ({ email, organization, invitation }) => {
await sendInvitation(email, organization.name, invitation.id)
}
})
]
})
// client
import { createAuthClient } from 'better-auth/react'
import { twoFactorClient, organizationClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
plugins: [twoFactorClient(), organizationClient()]
})Two-factor login supports three methods: time based codes following the TOTP standard, one-time codes sent through a separate channel, and backup codes. The endpoints are /two-factor/enable, /two-factor/disable, /two-factor/get-totp-uri, /two-factor/verify-totp, /two-factor/send-otp, /two-factor/verify-otp, /two-factor/generate-backup-codes and /two-factor/verify-backup-code. The plugin brings its own request limiting rule, three attempts per ten seconds, plus an optional account lockout after a set number of failed verifications.
The organization plugin is larger and covers the whole typical need of an application sold to companies. It exposes thirty five endpoints, including creating and deleting organizations, checking whether a textual identifier is free, setting the active organization on the session, the full invitation cycle with rejection and cancellation, member and role management, teams with a separate member list, and dynamic roles created at runtime once you enable dynamicAccessControl. You build the permission system with the createAccessControl function, supplying a set of resources and the actions allowed on them, and roles are subsets of that set.
Keep one thing in mind: every plugin that changes the schema requires running generate again and deploying a migration. Adding a plugin only in the configuration ends with a missing table error on the first request that touches it.
The security that moves onto your side
Passwords are hashed with scrypt using the parameters N of 16384, r of 16, p of 1 and a key length of 64 bytes, with a random sixteen byte salt. The result is stored as the salt and the key separated by a colon, both in hexadecimal. The parameters are sensible and need nothing from you, though you can swap the whole mechanism through emailAndPassword.password with its hash and verify fields. That is the only sensible route for migrating accounts from a system that used a different algorithm.
Request limiting is enabled by default in production only, with a ten second window and a limit of one hundred requests. The default counter storage is process memory, and that is where most deployments lose the protection without realising they have lost it. In a serverless environment, or behind a load balancer, every instance counts separately, so the effective limit multiplies by the number of instances. The fix is switching to the database or to a cache declared in secondaryStorage, Redis for instance.
export const auth = betterAuth({
rateLimit: {
enabled: true,
window: 10,
max: 100,
storage: 'secondary-storage',
customRules: {
'/sign-in/email': { window: 60, max: 5 },
'/request-password-reset': { window: 300, max: 3 }
}
},
advanced: {
ipAddress: {
ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'],
disableIpTracking: false
}
},
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => customHash(password),
verify: async ({ hash, password }) => customVerify(hash, password)
}
}
})Session and linked account management comes in the core, with no plugin needed. The /list-sessions, /revoke-session, /revoke-other-sessions and /revoke-sessions endpoints are enough to build a device list screen and a button that signs the user out everywhere except the current browser, which is what people expect after a password change. External provider links are handled by /link-social, /list-accounts and /unlink-account, while the rules for joining accounts by email address live in the account.accountLinking section. That last one deserves care: linking a password account to a social account on the address alone is convenient and can equally be a route to account takeover, if the provider does not verify the address in a way you can trust.
Telemetry is off by default, the telemetry.enabled field is false and does not change without your decision. That is something an auditor will check, so a clear answer helps.
The rest of the responsibility sits exactly where the deployment model put it. Tracking releases, reading security advisories, configuring headers, detecting dictionary attacks against specific accounts, the account recovery procedure, storing database backups that contain passwords. None of that happens by itself and nobody will send you a notice that a dependency needs updating. If nobody on the team will take that on, a hosted service works out cheaper regardless of the invoice.
Better Auth against the alternatives
Prices in the table come from vendor pricing pages checked on 20 August 2026 and refer to the self-service tiers.
| Tool | Deployment model | Where accounts live | Billing | Main limitation |
|---|---|---|---|---|
| Better Auth | Dependency inside your process | Your database | Server and database cost, no per-user fee | Maintenance and security are on you |
| Clerk | Hosted service | Vendor infrastructure | Free up to 50,000 retained users, Pro 25 USD per month, overage from 0.02 USD per user | The bill grows with the account base |
| Auth0 | Hosted service | Vendor infrastructure | Free plan up to 25,000 monthly active, Essentials from 35 USD, Professional from 240 USD | The base price of paid plans covers 500 users |
| Kinde | Hosted service | Vendor infrastructure | Free plan up to 10,500 monthly active, Pro 25 USD, extra user 0.0175 USD | A 0.7 percent transaction fee with the billing module |
| Supabase Auth | Managed service or self-hosted | A Supabase project, yours or hosted | Free plan up to 50,000 monthly active, Pro from 25 USD with 100,000 included, then 0.00325 USD per user | Ties authentication to the rest of the platform |
The table yields a simple split. If you have few users and little time, a hosted service is cheaper, because you pay close to nothing and maintain nothing. If you have many users of low individual value, free accounts in a freemium product for example, the per-active-user fee becomes a line item that can outgrow the cost of your entire remaining infrastructure. If you are bound by a requirement to keep personal data within your own jurisdiction or your own network, the choice narrows to the last two rows of the table.
The table leaves out one more variant that looks like Better Auth but is not. SuperTokens also lets you keep accounts on your own side, except not as a dependency inside your process but as a separate service: a core in a container, a library in the backend and a library in the frontend, with matching versions required between them. In exchange for that complexity you get a ready-made dashboard and a mature feature set. Two things are worth knowing before deciding: the ee directory in the repository carries a separate proprietary licence, and eight features listed in the core source as paid, among them multi-tenancy, multi-factor authentication and dashboard login, require a licence key even when self-hosting. Free dashboard accounts are capped at three.
Common mistakes
The first is a forgotten secret on the first deployment. In production the library will not start, which is the good news; the trouble comes when somebody copies the development secret into production to make a demo on time. The key signing sessions then lands in the repository along with the configuration file.
The second is trying to use the migrate command with Prisma or Drizzle. The tool prints a message pointing at the correct route, but it is easy to miss when the command runs inside a deployment script that swallows output. The correct order is generate, then a migration through the tool that owns the schema history.
The third is request limiting left in process memory on a multi-instance deployment. The symptom is an absence of symptoms, because the counter works and does block something, only at a threshold multiplied by the instance count. Checking takes a minute: send a burst of requests and count how many got through before a 429 response appeared.
The fourth is a plugin added on one side only. A server without the plugin returns 404 for the path the client calls, and a client without the plugin simply lacks the method you want to invoke. The error message in the second case comes from TypeScript and reads clearly; in the first it only shows up in production.
The fifth concerns Next.js and the ordering of the plugins array. The nextCookies plugin has to sit last, because it runs in a hook executed after the others and writes the headers that set cookies. Placed earlier it makes sign-in succeed on the server while the browser never receives a session.
The sixth is a missing entry in trustedOrigins when the frontend lives on a different domain than the authentication server. Requests are then rejected during the origin check, and the message does not always lead straight to the cause. You can supply the list in the configuration or through the BETTER_AUTH_TRUSTED_ORIGINS environment variable.
The seventh is treating the result of the useSession hook as the basis for an access decision. That is interface state, handy for showing an avatar or hiding a button, and nothing more. Every decision about data access has to be made on the server through auth.api.getSession. On top of that comes the cookie cache: with cookieCache enabled, revoking a session becomes visible only once the cache expires, five minutes by default.
FAQ
Does Better Auth require a specific database?
No. The adapters cover Drizzle, Prisma, MongoDB and Kysely, and Kysely in turn handles PostgreSQL, MySQL and SQLite. There is also an in-memory adapter for tests. If you use something outside that list, you write the adapter yourself by implementing the record operation interface.
Can existing accounts be migrated from another system?
They can, provided you have access to the password hashes. You insert rows into the user and account tables and swap the verification algorithm through emailAndPassword.password so it accepts the old format. Accounts based purely on social login migrate more easily, since all you need is the link between provider identifier and user.
Does the library send any telemetry?
Not by default. The telemetry.enabled field is false and requires an explicit opt-in. A separate telemetry package sits among the dependencies, but without that consent it sends nothing.
Is Better Auth suitable for a serverless environment?
It is, though two things then need moving off their defaults. Request limiting has to move from process memory to the database or to a cache, and with short function lifetimes it pays to consider enabling the session cache in a cookie to cut the number of database queries on every request.
How much work is self-hosted authentication compared with a hosted service?
The first run takes comparably little time either way; the difference shows up later. With a hosted service you maintain nothing and receive a finished security audit report. With your own installation you own updates, monitoring and account recovery procedures, which in practice means a few hours a month and one person keeping an eye on it.
Documentation lives on the project site, the source code in the GitHub repository, and the published package metadata in the npm registry.