Most dependencies are replaceable. You can swap a date library in an afternoon and a state manager in a weekend, because the blast radius is your own code. Auth is different, because its state does not live in your code. It lives in password hashes in a database, in session cookies sitting in browsers you do not control, and in OAuth account links that pair your user IDs with Google's. Whatever you choose, you are really choosing where that data lives and what it costs to move.
That gives you the only taxonomy worth using. On one side sit the systems where the user table lives in your own database: NextAuth with a database adapter, Better Auth, and hand-rolled sessions. On the other, systems where it lives in someone else's cloud: Clerk and Auth0. Supabase Auth straddles the line — a hosted service writing into your own Postgres. Every other comparison point, provider lists and plugin counts and developer experience, matters less than this one, because this is the one you cannot refactor your way out of.
In the modern full-stack overview I called auth one of the load-bearing picks in a 2026 stack. This article is the long version of that chapter, written from the slightly bruised position of actually running one of these libraries in production.
warning
Write the user-export script in week one, while it is trivial: a query that dumps users, password hashes, and OAuth account links to a file. It costs ten minutes today and is your migration insurance forever. If a system — library or hosted — cannot produce that file, you have just learned the most important thing about it.
From roughly 2023 to 2024, Lucia was the thinking developer's answer to NextAuth: a small, readable session library from a single maintainer, with documentation good enough that people read it for fun. Then the author deprecated it. Not because of a security failure or a rug-pull — the conclusion was more interesting than that. Maintaining the package meant absorbing endless adapter requests and framework edge cases, while the actual core of the problem, database-backed sessions, was a couple of hundred lines that most developers could own themselves. So he sunset the library and rebuilt the project as a learning resource that teaches you to write that code.
Take both lessons from that. The first is about dependency risk: an auth library maintained by one person is one burnout away from becoming your problem, and auth is the worst place to inherit a problem, because you cannot simply stop patching it. Before adopting any auth dependency, check the bus factor as seriously as you would check its security history. The second lesson is the author's own: sessions are small. That insight outlived the library that carried it, and it is why rolling your own gets a serious section below instead of a warning sticker.
What Lucia is not, in 2026, is a candidate. If an article recommends installing it, the article is dated — which makes it a usefully reliable freshness test for everything else that article claims.
NextAuth is the incumbent. It rebranded to Auth.js as it grew beyond Next.js, though its centre of gravity never really moved — and its provider list remains the longest in the ecosystem by a wide margin. Not just Google and GitHub but the long tail: Keycloak, Okta, Azure AD, the identity provider your enterprise client insists on. If the requirement is "log in with anything", NextAuth probably has the config for it already.
The v5 story tells you the rest. The redesign was genuinely good — one auth() call that behaves identically in server components, route handlers, server actions, and middleware, replacing the three different session APIs of v4:
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect("/login");
return <h1>Welcome back, {session.user.name}</h1>;
}The release process was not good. The beta stretched across years, documentation for v4 and v5 coexisted and contradicted each other, and tutorials written six months apart described what looked like different libraries. The internals are opaque in a way you only discover at debugging time: when a callback fires in an order you did not expect, or a session cookie outgrows the size limit and gets silently chunked, the error messages give you almost nothing, and you end up reading the library's source to understand your own login flow.
This site runs the v5 beta in production with Google, GitHub, and credentials sign-in, so I will be precise about the deal. The OAuth plumbing — handshakes, CSRF, cookie handling — is excellent and has years of production scrutiny behind it. Everything above that line is yours. NextAuth ships no multi-factor auth, treats email-and-password as a deliberately second-class citizen, and offers callbacks where you might expect features: our TOTP MFA — enrollment, QR codes, recovery codes, the verification step wedged into the credentials flow — is entirely hand-built. The complete setup, including the parts the library leaves to you, is in the NextAuth v5 guide.
tip
If you run the v5 beta in production, pin the exact version in package.json — no caret — and read the changelog before every bump. The beta has been stable in practice for a long time, but "beta" still means the maintainers reserve the right to move things.
Better Auth is the rising option, and it grew fast because its pitch lands exactly where NextAuth users are sore: most applications need the same ten auth features, so the library should ship them instead of pointing you at a blog post. It is TypeScript-first, framework-agnostic, and built around a plugin architecture. The core covers email-and-password, social providers, and database-backed sessions; plugins add two-factor auth, passkeys, and organizations with roles and invitations. Rate limiting on auth endpoints is built in rather than left as an exercise.
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { twoFactor, organization } from "better-auth/plugins";
import { prisma } from "@/lib/prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [twoFactor(), organization()],
});Those two plugin calls are the headline. On top of NextAuth, two-factor auth alone is a multi-week project — I know because we built it — and organizations are a schema design exercise you do unsupervised. Just as important is where the data lands: Better Auth's CLI generates its tables into your own database, through Prisma, Drizzle, or plain SQL. Your users, hashes, sessions, and org structures sit in your Postgres, in readable tables, so the exit cost stays bounded even if the project vanished tomorrow.
The honest trade-offs are all variations of young. NextAuth's core has been hammered in production for years; Better Auth's much larger surface — every plugin is auth-critical code — has had a fraction of that time under load. Plugin schemas evolve, which means database migrations you did not plan. And after Lucia, sustainability deserves an explicit line in your evaluation: the project is active and widely adopted, but "the direction is good" was said about every project that later stalled. The schema ownership is exactly what makes that bet acceptable — the failure mode is a fork or a slow migration, not a hostage situation.
Every library comparison hides a question in plain sight: why install one at all? Clerk gives you sign-in components, MFA, organizations, and a dashboard a non-technical cofounder can operate, in an afternoon. Auth0 has been selling the enterprise version of the same promise for over a decade. For a two-person startup whose biggest risk is irrelevance rather than lock-in, taking that deal is often simply correct, and pretending otherwise is wishful thinking from people who enjoy building auth.
The costs are just as real, and they compound. Pricing is per monthly active user, which means your auth bill grows precisely when your product succeeds — a generous free tier becomes a serious line item without you changing a single line of code, and you discover the jump at the worst possible moment, mid-growth. And your users live in someone else's database. Export paths exist, but an export is a project: data to sync out, password hashes to request — some providers hand them over, on some plans — and live sessions that will not survive the move. You will run that project under pressure, because nobody migrates auth while things are going well.
Supabase Auth occupies the middle of this trade: a hosted service you do not maintain, with users stored in your own Postgres next to the rest of your data. If your stack already leans on Supabase it is the natural default, and the Supabase authentication guide covers that path end to end, including row-level security — the part that actually requires thinking.
This is what the Lucia author now recommends learning, and after years of treating hand-rolled auth as a punchline, the honest position has shifted: rolling your own OAuth is still a bad idea, but rolling your own sessions is a bounded, learnable problem. The core fits in one file:
import { createHash } from "node:crypto";
import { prisma } from "@/lib/prisma";
const hash = (token: string) =>
createHash("sha256").update(token).digest("hex");
export async function validateSession(token: string) {
const session = await prisma.session.findUnique({
where: { id: hash(token) },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt < new Date()) {
await prisma.session.delete({ where: { id: session.id } });
return null;
}
return session;
}A sessions table, a long random token generated at login, hashed before it is stored so a database leak does not leak live sessions, an expiry check, a delete on the way out. Add a create function and sliding expiration and the whole layer is under two hundred lines you can read in one sitting and own forever. The migration story is the best on this page, because there is nothing to migrate away from.
What makes it genuinely reasonable is scope. Email-and-password plus perhaps one OAuth provider through a small client library: fine, and a better education than any tutorial. The moment the requirements say passkeys, organizations, or an enterprise provider list, you are rebuilding Better Auth one incident at a time. And the things you must get right are not optional:
- Password hashing with argon2id, or bcrypt at a work factor of 12 or more — never a fast hash like SHA-256 for passwords, never a scheme you invented.
- Session rotation: issue a fresh token at login and on every privilege change, so a session fixed or leaked before sign-in is worthless afterwards.
- CSRF protection: SameSite cookies as the floor, plus an Origin header check on every state-changing request — SameSite alone does not cover every flow.
- Rate limiting on login, registration, and password-reset endpoints — credential stuffing is automated, constant, and completely indifferent to how small your app is.
Notice that this list is not really about DIY. It is the security baseline any option on this page must clear — real password hashing, httpOnly and Secure cookies, rate limiting on auth endpoints, and a credible path to MFA. The libraries clear different parts of it for you; none of them clears all of it. Audit whatever you pick against those four lines before launch.
Here is the rule by scenario, in the order I would actually check the conditions.
- Side project or learning build: roll your own sessions, on purpose. It is the best auth education available and the failure cost is low. If the goal is shipping rather than learning, Better Auth — you will have 2FA working before your first real user asks for it.
- Production SaaS: Better Auth is the 2026 default — two-factor, passkeys, and organizations as configuration rather than as projects, with the schema in your own Postgres. Choose NextAuth instead if your sign-in story is OAuth-heavy or needs a long-tail provider, and you accept building MFA and lockout yourself. Already on Supabase: use its auth and spend the saved week on row-level security.
- Enterprise SSO: this is where hosted earns its invoice. SAML, SCIM provisioning, and compliance checkboxes are exactly the undifferentiated misery Auth0 and WorkOS exist to absorb. NextAuth can reach many of the same providers if the budget says no — but you own the configuration burden and the support calls.
- Whatever you pick: if the user table cannot live in your database, demand a tested export path before you write the first line of integration code.
Then commit properly. Spike the winner in a branch this week: one real sign-in, one protected page, and the export script from the top of this article, all working before you build a single feature on top. Auth is the layer everything else stands on. Wire it once, correctly, and it becomes the most boring part of your app for years — which is the highest compliment auth can earn.
