better-auth en Express
better-auth es un framework de autenticación TypeScript-first y agnóstico de framework: resuelve lo mismo que JWT + bcrypt + cookies armado a mano (hashear contraseñas, emitir y validar una sesión, manejar providers OAuth), pero como solución lista — tú configuras qué métodos de login quieres, y la librería maneja el resto. A diferencia de Auth.js (históricamente atado a Next.js), su core es agnóstico desde el diseño, con integración oficial para Express, Astro y Next.js por igual.
Antes de empezar
Sección titulada «Antes de empezar»Esta integración parte de un proyecto Express 5 y un cliente Prisma configurado para PostgreSQL. Sigue primero Prisma en express. Mantén una sola carpeta de helpers e imports coherentes. Define BETTER_AUTH_SECRET y BETTER_AUTH_URL en el entorno del servidor; configura las credenciales GitHub solo si activarás ese proveedor.
Con Prisma, la CLI de Better Auth genera el esquema, y Prisma crea/aplica la migración. Hazlo en desarrollo y revisa el diff antes de aplicar las migraciones versionadas en producción. auth@latest sigue el canal actual: registra la versión resuelta cuando reproduzcas esta guía.
Instalación
Sección titulada «Instalación»pnpm add better-auth @better-auth/prisma-adapterbun add better-auth @better-auth/prisma-adapternpm install better-auth @better-auth/prisma-adapterConfiguración rápida — de cero a un endpoint funcionando
Sección titulada «Configuración rápida — de cero a un endpoint funcionando»1. Configurar el core (adapter de base de datos + providers):
import { betterAuth } from "better-auth"import { prismaAdapter } from "better-auth/adapters/prisma"import { prisma } from "./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! } }})El database usa un adapter — better-auth no impone un ORM: hay adapters para Prisma, Drizzle, Kysely y SQL directo. prismaAdapter es el más común si el proyecto ya usa Prisma.
2. Generar y aplicar las migraciones del schema de auth (better-auth necesita tablas propias de usuario/sesión):
pnpm dlx auth@latest generatepnpm exec prisma migrate dev --name add-authpnpm exec prisma generate3. Montar el handler — sí necesita una ruta, un catch-all para toda /api/auth/*:
import { toNodeHandler } from "better-auth/node"import express from "express"import { auth } from "./lib/auth"
const app = express()
// Tiene que ir ANTES de express.json() — better-auth necesita el body sin parsearapp.all("/api/auth/{*splat}", toNodeHandler(auth))
app.use(express.json())// ... el resto de las rutas ...toNodeHandler convierte el handler agnóstico de framework de better-auth en algo que Express puede montar directo — a partir de ahí, /api/auth/sign-in, /api/auth/sign-up, los callbacks de OAuth, etc. ya funcionan sin escribirlos a mano. No hace falta armar rutas propias de login/registro — este único app.all las reemplaza todas.
Modelo de sesión
Sección titulada «Modelo de sesión»better-auth emite una cookie de sesión httpOnly al hacer login — no un JWT que el cliente decodifica, sino un identificador que el servidor valida contra la base en cada request (sesión “server-side”, más fácil de revocar que un JWT stateless).
Leer la sesión en un middleware propio
Sección titulada «Leer la sesión en un middleware propio»import { fromNodeHeaders } from "better-auth/node"import type { NextFunction, Request, Response } from "express"import { auth } from "../lib/auth"
declare global { namespace Express { interface Request { user?: { id: string; email: string } } }}
export async function requireAuth( req: Request, res: Response, next: NextFunction) { const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) })
if (!session) { return res.status(401).json({ error: "No autenticado" }) }
req.user = session.user next()}app.get("/perfil", requireAuth, (req, res) => { res.json({ userId: req.user!.id })})Mismo shape de resultado (req.user poblado) que el middleware de auth manual — el resto de las rutas protegidas no necesita saber si la sesión viene de JWT manual o de better-auth.
Login con email/password desde el cliente
Sección titulada «Login con email/password desde el cliente»better-auth también expone un client (better-auth/client) para no armar el fetch a mano contra /api/auth/sign-in:
import { createAuthClient } from "better-auth/client"
export const authClient = createAuthClient({ baseURL: "http://localhost:3000" })await authClient.signIn.email({ email, password })await authClient.signUp.email({ email, password, name })await authClient.signOut()Roles y datos custom del usuario
Sección titulada «Roles y datos custom del usuario»Para agregar campos propios (como rol) al usuario que better-auth gestiona:
export const auth = betterAuth({ // ... user: { additionalFields: { rol: { type: "string", defaultValue: "user", input: false } } }})Después de correr pnpm dlx auth@latest generate de nuevo (para que la migración incluya el campo nuevo), session.user.rol queda disponible donde sea que se lea la sesión.
Piezas de la integración
Sección titulada «Piezas de la integración»| Pieza | Rol |
|---|---|
betterAuth({ database, ...providers }) | Configuración central |
auth generate + prisma migrate dev | Genera y aplica el schema de auth |
toNodeHandler(auth) en /api/auth/* | Expone todos los endpoints de auth, sin rutas propias |
auth.api.getSession({ headers }) | Leer la sesión actual dentro de un middleware/ruta propia |
user.additionalFields | Agregar campos custom (como rol) al usuario |
Cuándo adoptar Better Auth
Sección titulada «Cuándo adoptar Better Auth»- Requiere una base de datos configurada desde el inicio (vía el adapter que corresponda) — el trade-off frente a JWT manual es menos control fino a cambio de no reinventar hashing, expiración, refresh y providers OAuth.
- Sesión server-side significa una consulta a la base (o cache) por request autenticado, a diferencia de un JWT que se valida sin tocar la base.
- Los endpoints que genera (
/api/auth/*) reemplazan por completo rutas propias de/login,/registro, etc. — no conviene tener ambos sistemas (JWT manual y better-auth) activos para el mismo flujo al mismo tiempo.
Comprobación y límites
Sección titulada «Comprobación y límites»Prueba registro, login incorrecto, login correcto, lectura de sesión y logout. Sin sesión, una página protegida debe redirigir y una API debe devolver 401. Prueba también un usuario autenticado sin permiso: debe recibir 403 y no modificar datos.
El campo rol usa input: false para que el formulario no pueda asignar privilegios. Cambia permisos solo desde una operación autorizada en el servidor. Un campo adicional por sí solo no implementa control de acceso; valida permisos y pertenencia del recurso en cada operación. Si expones roles al cliente, configura también la inferencia de campos adicionales según la API del cliente utilizada.