Ir al contenido
angel.library

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.

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.

Ventana de terminal
pnpm add better-auth @better-auth/prisma-adapter

Configuració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):

lib/auth.ts
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):

Ventana de terminal
pnpm dlx auth@latest generate
pnpm exec prisma migrate dev --name add-auth
pnpm exec prisma generate

3. Montar el handler — sí necesita una ruta, un catch-all para toda /api/auth/*:

app.ts
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 parsear
app.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.

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).

middlewares/requireAuth.ts
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.

better-auth también expone un client (better-auth/client) para no armar el fetch a mano contra /api/auth/sign-in:

lib/auth-client.ts
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()

Para agregar campos propios (como rol) al usuario que better-auth gestiona:

lib/auth.ts
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.

PiezaRol
betterAuth({ database, ...providers })Configuración central
auth generate + prisma migrate devGenera 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.additionalFieldsAgregar campos custom (como rol) al usuario
  • 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.

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.