Skip to main content
Security12 min readMarch 2, 2026

Modern Authentication in TypeScript: Lucia, Better-Auth, and When to Roll Your Own

A practical comparison of TypeScript authentication approaches in 2026 — Lucia, better-auth, NextAuth, and custom solutions — with clear guidance on when each makes sense.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Modern Authentication in TypeScript: Lucia, Better-Auth, and When to Roll Your Own

Authentication is the kind of problem that looks simple until you are three days into implementing password reset flows and realize you have not considered session invalidation across devices, CSRF protection on your token endpoint, or what happens when your database goes down mid-authentication. Every team underestimates it, and the ones that recover fastest are the ones who picked the right library early.

The TypeScript authentication landscape in 2026 is better than it has ever been. But "better" does not mean "obvious." There are at least four credible approaches, each with real tradeoffs. I have used all of them in production. Here is what I have learned.

The Authentication Landscape in 2026

Three things have shifted the ground under authentication in the last two years.

First, passkeys have gone from interesting demo to production-ready default. WebAuthn browser support is effectively universal, and users are increasingly expecting passwordless options. Any auth solution you pick needs to support passkeys natively or get out of the way so you can add them.

Second, the compliance landscape has tightened. GDPR enforcement actions are up. SOC 2 audits are asking detailed questions about session management. If you are building anything that handles user data — which is everything — your authentication layer is going to be scrutinized. The days of shipping a bcrypt-and-JWT stack with no audit trail are numbered.

Third, the TypeScript ecosystem has matured to the point where type-safe authentication is a reasonable expectation. You should not be casting req.user to any in 2026. Your auth library should give you typed sessions, typed user objects, and compile-time guarantees that you are checking authentication before accessing protected data.

With that context, let us look at the options.

Lucia: The Library That Gives You Control

Lucia is session-based authentication that stays out of your way. It handles session creation, validation, and invalidation. It does not handle OAuth flows, password hashing, email verification, or anything else. You build those yourself, using Lucia's sessions as the foundation.

This sounds like more work, and it is. But it is the right kind of work for certain projects.

Lucia is database-agnostic — you provide an adapter for your database, and it stores sessions wherever you want. PostgreSQL, SQLite, MongoDB, Turso, it does not care. This is critical if you have an existing database schema that you cannot reshape around an auth library's opinions.

Here is what a Lucia session setup looks like in practice:

import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import { prisma } from "./db";

const adapter = new PrismaAdapter(prisma.session, prisma.user);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
    },
  },
  getUserAttributes: (attributes) => {
    return {
      email: attributes.email,
      role: attributes.role,
    };
  },
});

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: {
      email: string;
      role: "admin" | "user";
    };
  }
}

Notice the declare module block at the bottom. This is where Lucia earns its keep in TypeScript projects — your session user attributes are fully typed throughout your entire application. When you call lucia.validateSession(sessionId), the returned user object has email and role as properly typed fields, not some Record<string, unknown> you have to cast.

The tradeoff is clear: Lucia gives you typed sessions and nothing else. You write your own login endpoint, your own registration flow, your own password reset. For teams that want full control over the authentication UX and already have opinions about how password hashing and email verification should work, this is a feature. For teams that want to ship fast, it is a cost.

Better-Auth: Convention Over Configuration

Better-auth takes the opposite approach. It is batteries-included authentication for TypeScript — you configure it once, and it gives you login, registration, password reset, email verification, OAuth, session management, and an admin panel.

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./db";

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // refresh daily
  },
});

That configuration gives you a complete authentication system. Better-auth generates the database tables it needs, provides API endpoints you can mount on your server, and ships a client SDK for your frontend. If you are building a new application and want to spend your time on business logic instead of auth plumbing, this is compelling.

The cost is flexibility. Better-auth has opinions about your database schema. It wants specific table names and column structures. If you have an existing user table with a different shape, you are going to fight the framework. And when you need to customize behavior — say, adding a custom claim to sessions or integrating with an external identity provider that is not in the supported list — you are working within someone else's abstraction.

NextAuth/Auth.js: The Ecosystem Play

Auth.js (the framework-agnostic evolution of NextAuth) is the most widely deployed TypeScript auth library. It has the largest ecosystem of providers, the most Stack Overflow answers, and the most battle-tested production deployments.

If you are building with Next.js, Auth.js is the path of least resistance. The integration is tight, the documentation assumes your stack, and most tutorials you find will use it.

But Auth.js has real limitations that show up in production. The session strategy defaults to JWT, which means logout does not actually invalidate anything — the token is valid until it expires. You can switch to database sessions, but the documentation buries this and the default behavior surprises teams who expect logout to work immediately. The TypeScript types have improved significantly, but the adapter interface is still looser than I would like. And if you are not using Next.js, the framework-agnostic version works but feels like an afterthought compared to the Next.js integration.

I reach for Auth.js when building Next.js applications for clients who need something proven and widely understood by future developers who will maintain the codebase. I do not reach for it when I need precise control over session behavior.

Rolling Your Own: When It Makes Sense

The conventional wisdom is "never roll your own auth." That is good advice for most teams. But there are legitimate reasons to build authentication from scratch, and pretending otherwise is not honest.

You should consider building your own authentication when you have compliance requirements that no library satisfies out of the box — think FedRAMP, healthcare systems with specific audit logging mandates, or financial applications where every authentication event must be recorded in a specific format. When the cost of bending a library to meet your requirements exceeds the cost of building from proven primitives, building makes sense.

The key phrase is "proven primitives." Rolling your own does not mean implementing your own bcrypt. It means using Argon2id for password hashing, using your database for session storage, implementing CSRF protection with double-submit cookies, and wiring it all together yourself. You are assembling known-good components, not inventing cryptography. I have written about the fundamentals that underpin this approach in my authentication security guide — if you are going this route, that is prerequisite reading.

For the vast majority of projects, a library is the right choice. The edge cases where custom auth is justified are real but rare.

Decision Matrix

Here is how I think about the choice, based on the variables that actually matter:

Solo developer or small team, new project, ship fast

Pick better-auth. The convention-over-configuration approach means you spend an afternoon on auth instead of a week. The opinions it imposes on your schema are reasonable, and you are not fighting an existing database.

Existing application, established database schema

Pick Lucia. You need session management that adapts to your schema, not a library that demands you adapt to it. Lucia's adapter model lets you slot sessions into whatever you already have.

Next.js application, team will have future developers

Pick Auth.js. The ecosystem advantage matters for hiring and onboarding. Future developers will recognize the patterns. The JWT-session tradeoff is manageable if you understand it going in.

Strict compliance, unusual audit requirements

Build from primitives. But only if your team has senior engineers who understand session management, CSRF protection, and token security deeply. This is not a task for junior developers.

Team size under 5, standard SaaS product

Pick better-auth or Lucia, depending on whether you value speed (better-auth) or control (Lucia). Either is a solid choice. Auth.js is fine too if you are already on Next.js.

My Recommendation for Most Projects

If I am starting a new TypeScript project today and the team asks me to choose an auth solution, I am picking better-auth for most cases. The development speed advantage is real, the TypeScript integration is strong, and the default security posture is solid. You get session-based auth with proper invalidation, password hashing with Argon2id, CSRF protection, and rate limiting without writing any of it yourself.

If the project has an existing database with a user table that cannot change shape, or if I need authentication to work across multiple services that do not share a database, I am picking Lucia. The minimal surface area is an advantage when you need to integrate authentication into a system rather than build a system around authentication.

And whatever you pick, get your encryption story right before you go to production. Authentication tells you who someone is. Encryption ensures that nobody else can read what they are doing. Both are non-negotiable.

The best auth library is the one your team understands completely. A simple Lucia setup that every developer on your team can debug at 2 AM is worth more than a sophisticated better-auth configuration that only one person understands. Pick the tool that matches your team's expertise, then invest the time to understand it deeply.


Keep Reading