Flujo de autenticación con Drizzle y base de datos Neon sobre fondo oscuro
Flujo de autenticación con Drizzle y base de datos Neon sobre fondo oscuro

Better Auth con Drizzle y Neon en Astro

La autenticación es de esas cosas que parecen simples hasta que llevas una hora metido y te das cuenta de que has reimplementado a mano un almacén de sesiones. Better Auth es una librería de autenticación pensada para TypeScript que se encarga de todo eso — sesiones, proveedores OAuth, passkeys, roles de administrador — y se integra limpiamente con Drizzle ORM.

Este post recorre la configuración completa en un proyecto Astro: aprovisionar la base de datos, conectar el schema y dejar funcionando los cuatro métodos de autenticación.

Qué vamos a construir

Agregar a un proyecto existente

Si ya tienes Astro + Drizzle configurados, solo necesitas algunos paquetes:

bun add better-auth @better-auth/passkey drizzle-orm @neondatabase/serverless
bun add -d @better-auth/cli

Para un proyecto nuevo, primero crea el proyecto Astro (bun create astro) y luego agrega drizzle-orm, drizzle-kit y @neondatabase/serverless antes de continuar.

Aprovisionar una base de datos Neon

Crea un proyecto en neon.tech y copia el string de conexión. Agrégalo a .env:

POSTGRES_URL=postgresql://usuario:contraseña@host/basededatos?sslmode=require

El ?sslmode=require es importante — Neon exige TLS y la conexión será rechazada sin él.

Variables de entorno

# Base de datos
POSTGRES_URL=postgresql://usuario:contraseña@host/basededatos?sslmode=require

# Better Auth
BETTER_AUTH_SECRET=cadena-aleatoria-de-32-caracteres-o-mas
BETTER_AUTH_URL=http://localhost:4321   # producción: https://tudominio.com

# OAuth social — omite el proveedor que no necesites
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=

Genera BETTER_AUTH_SECRET con:

openssl rand -base64 32

Los dos archivos de configuración

Esta es la parte más complicada del setup, y la razón por la que las cosas se rompen si solo tienes un archivo.

La CLI de Better Auth (auth:generate, que regenera el schema de Drizzle) necesita importar tu configuración en tiempo de build a través de Node.js — pero el módulo astro:env de Astro solo funciona dentro del runtime de Astro. Si apuntas la CLI a tu config de runtime, falla.

La solución: dos archivos con los mismos plugins pero distintas fuentes de variables de entorno.

auth.config.ts — solo para la CLI

Vive en la raíz del proyecto. Lo usa únicamente bunx @better-auth/cli. Lee las variables desde process.env vía dotenv.

// auth.config.ts
import "dotenv/config";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, username } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./src/db/schema";

const client = postgres(process.env.POSTGRES_URL!, { prepare: false });
const db = drizzle(client, { schema });

export default betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: {
      user: schema.users,
      session: schema.sessions,
      account: schema.accounts,
      verification: schema.verifications,
      passkey: schema.passkeys,
    },
  }),
  emailAndPassword: { enabled: true },
  plugins: [
    admin({ defaultRole: "user", adminRoles: ["admin"] }),
    passkey({
      rpID: "localhost",
      rpName: "Mi App",
      origin: "http://localhost:4321",
    }),
    username({ minLength: 3, maxLength: 30 }),
  ],
});

src/lib/auth.ts — runtime

Lo usa el servidor de Astro. Lee las variables desde astro:env, incluye los proveedores sociales y adapta la configuración de passkeys según el entorno.

// src/lib/auth.ts
import { BETTER_AUTH_URL } from "astro:env/client";
import {
  BETTER_AUTH_SECRET,
  GITHUB_CLIENT_ID,
  GITHUB_CLIENT_SECRET,
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  FACEBOOK_CLIENT_ID,
  FACEBOOK_CLIENT_SECRET,
} from "astro:env/server";
import { passkey } from "@better-auth/passkey";
import { db } from "@db";
import * as schema from "@db/schema";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, username } from "better-auth/plugins";

const isProd = import.meta.env.PROD;

const socialProviders: Record<
  string,
  { clientId: string; clientSecret: string }
> = {};
if (GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET)
  socialProviders.github = {
    clientId: GITHUB_CLIENT_ID,
    clientSecret: GITHUB_CLIENT_SECRET,
  };
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET)
  socialProviders.google = {
    clientId: GOOGLE_CLIENT_ID,
    clientSecret: GOOGLE_CLIENT_SECRET,
  };
if (FACEBOOK_CLIENT_ID && FACEBOOK_CLIENT_SECRET)
  socialProviders.facebook = {
    clientId: FACEBOOK_CLIENT_ID,
    clientSecret: FACEBOOK_CLIENT_SECRET,
  };

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: {
      user: schema.users,
      session: schema.sessions,
      account: schema.accounts,
      verification: schema.verifications,
      passkey: schema.passkeys,
    },
  }),
  secret: BETTER_AUTH_SECRET,
  baseURL: BETTER_AUTH_URL,
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false,
    minPasswordLength: 8,
  },
  socialProviders,
  session: {
    cookieCache: { enabled: true, maxAge: 60 * 5 },
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
  },
  plugins: [
    admin({ defaultRole: "user", adminRoles: ["admin"] }),
    passkey({
      rpID: isProd ? "tudominio.com" : "localhost",
      rpName: "Mi App",
      origin: BETTER_AUTH_URL,
    }),
    username({ minLength: 3, maxLength: 30 }),
  ],
  advanced: {
    cookiePrefix: "myapp",
    useSecureCookies: isProd,
  },
  trustedOrigins: [BETTER_AUTH_URL],
});

export type Auth = typeof auth;

La regla es simple: si agregas un plugin a un archivo, agrégalo al otro.

Gestión del schema

Al ejecutar bun run auth:generate se escribe el schema de Drizzle en auth-schema.ts en la raíz del proyecto. En lugar de copiar ese archivo manualmente a src/db/ después de cada regeneración, crea un symlink una sola vez:

ln -s ../../auth-schema.ts src/db/auth-schema.generated.ts

Luego crea una re-exportación estable que renombra las tablas a la convención plural que prefiere Drizzle:

// src/db/schema/auth/index.ts
export {
  account as accounts,
  passkey as passkeys,
  session as sessions,
  user as users,
  verification as verifications,
} from "../../auth-schema.generated";

Así, bun run auth:generate regenera el archivo raíz y todos los imports dentro de src/db/ recogen los cambios automáticamente.

Ejecutar las migraciones

bun run auth:generate   # regenera auth-schema.ts vía la CLI
bun run db:generate     # crea un nuevo archivo SQL en drizzle/
bun run db:migrate      # lo aplica a la base de datos

Exponer el endpoint de la API de autenticación

Better Auth necesita una ruta catch-all. En Astro:

// src/pages/api/auth/[...all].ts
import type { APIRoute } from "astro";
import { auth } from "@lib/auth";

export const ALL: APIRoute = ({ request }) => auth.handler(request);

Configuración de OAuth social

Para cada proveedor que quieras usar, crea una aplicación OAuth en la consola de desarrolladores del proveedor y configura la URL de callback:

https://tudominio.com/api/auth/callback/<proveedor>

Para desarrollo local:

http://localhost:4321/api/auth/callback/github
http://localhost:4321/api/auth/callback/google
http://localhost:4321/api/auth/callback/facebook

Los proveedores sociales son opt-in: si las variables de entorno de un proveedor no están configuradas, ese proveedor simplemente no se registra.

Passkeys

Las passkeys usan WebAuthn. Los dos valores críticos son rpID (el dominio, sin protocolo ni puerto) y origin (la URL completa).

dev:  rpID = 'localhost',        origin = 'http://localhost:4321'
prod: rpID = 'tudominio.com',    origin = 'https://tudominio.com'

Equivocarse en estos valores es el error más común con passkeys — el navegador rechazará el registro silenciosamente si rpID no coincide con el dominio actual.

Agregar un plugin más adelante

  1. Instalar: bun add @better-auth/plugin-nombre
  2. Agregar a ambos archivos auth.config.ts y src/lib/auth.ts
  3. bun run auth:generate — actualiza auth-schema.ts
  4. Si se agregaron nuevas tablas, re-exportarlas en src/db/schema/auth/index.ts
  5. bun run db:generatebun run db:migrate

¿Qué pasa con los datos específicos de la app?

Nunca agregues columnas personalizadas a las tablas de autenticación — Better Auth las gestiona. En su lugar, crea una tabla user_profiles separada con una clave foránea a users.id:

// src/db/schema/user-profiles.ts
import { pgTable, text, uuid } from "drizzle-orm/pg-core";
import { users } from "./auth";

export const userProfiles = pgTable("user_profiles", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  bio: text("bio"),
  website: text("website"),
});

Esto mantiene el schema limpio: las tablas de autenticación se regeneran libremente, los datos de la app permanecen estables.

Lista de verificación rápida

¡Link copiado!

Comments for bttrth