lenec ru

← все посты

Better Auth с Drizzle и Postgres: настройка с нуля

12K

Этот сетап у меня сейчас стоит на трёх сервисах в проде, поэтому опишу его конкретно: какие файлы, какие команды, какие подводные. Цель — за час получить работающий вход через email/пароль и Google OAuth, на Postgres и Drizzle, чтобы дальше можно было обвешивать плагинами.

Версии на момент написания: Better Auth 1.1.x, Drizzle 0.30, drizzle-kit 0.21, Node 20, Postgres 16. На Next 14 пример показан, но логика одинаковая для Hono или Express.

Установка

pnpm add better-auth drizzle-orm pg
pnpm add -D drizzle-kit @types/pg tsx

Никаких отдельных адаптеров для Better Auth ставить не надо: better-auth/adapters/drizzle идёт в основном пакете.

База и подключение Drizzle

Начну с самого нижнего слоя. src/db/index.ts:

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
});

export const db = drizzle(pool, { schema });

В .env:

DATABASE_URL=postgres://app:app@localhost:5432/app
BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

Секрет генерирую один раз и кладу в менеджер секретов в проде. BETTER_AUTH_URL — публичный адрес приложения, по которому будут возвращаться OAuth-провайдеры.

Конфигурация auth

Файл src/lib/auth.ts. Это основа, которую переиспользуют и серверные обработчики, и React-клиент.

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '../db';

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
  },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 дней
    updateAge:  60 * 60 * 24,     // обновляем сессию раз в сутки
  },
  advanced: {
    cookiePrefix: 'app',
    useSecureCookies: process.env.NODE_ENV === 'production',
  },
});

На что обращу внимание:

  • requireEmailVerification: true — без него регистрация подтверждает email самостоятельно, что плохо. С этой опцией пользователь должен подтвердить ссылку из письма перед первым входом.
  • updateAge — раз в сутки сессия в БД обновляется. Реже — sliding-expiration не работает; чаще — лишняя запись на каждый запрос.
  • cookiePrefix важен в монорепе с несколькими приложениями: иначе куки от auth перехлестнутся.

Схема БД

Better Auth ожидает четыре таблицы: user, session, account, verification. Имена и колонки можно посмотреть в better-auth/cli, но я предпочитаю руками держать в Drizzle-схеме — чтобы видеть, какие поля где.

// src/db/schema.ts
import { pgTable, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core';

export const user = pgTable('user', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  emailVerified: boolean('email_verified').notNull().default(false),
  name: text('name'),
  image: text('image'),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

export const session = pgTable('session', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  token: text('token').notNull().unique(),
  expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

export const account = pgTable('account', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  providerId: text('provider_id').notNull(),
  accountId: text('account_id').notNull(),
  password: text('password'),
  accessToken: text('access_token'),
  refreshToken: text('refresh_token'),
  idToken: text('id_token'),
  scope: text('scope'),
  accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
  refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

export const verification = pgTable('verification', {
  id: text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value: text('value').notNull(),
  expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

Дальше — обычная связка drizzle-kit:

pnpm drizzle-kit generate
pnpm drizzle-kit migrate

Если ты хочешь, чтобы Better Auth сам сделал DDL — есть npx @better-auth/cli generate, он положит файл схемы. Я предпочитаю руками контролировать структуру.

Подключение к Next.js

Создаём catch-all-роут. app/api/auth/[...all]/route.ts:

import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';

export const { GET, POST } = toNextJsHandler(auth.handler);

Всё. Better Auth сам разруливает /api/auth/sign-in, /api/auth/sign-up, /api/auth/callback/google, /api/auth/sign-out и прочие endpoint-ы.

Клиент

// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL!,
});

export const { useSession, signIn, signUp, signOut } = authClient;

В компонентах:

'use client';
import { useSession, signIn, signOut } from '@/lib/auth-client';

export function UserBadge() {
  const { data: session, isPending } = useSession();
  if (isPending) return null;
  if (!session) return <button onClick={() => signIn.social({ provider: 'google' })}>Войти</button>;
  return (
    <div>
      Привет, {session.user.name}
      <button onClick={() => signOut()}>Выйти</button>
    </div>
  );
}

Серверная проверка сессии

В Next.js Server Components я делаю так:

import { auth } from '@/lib/auth';
import { headers } from 'next/headers';

export async function getSession() {
  return auth.api.getSession({ headers: headers() });
}

В Server Action или middleware — то же самое, разница только в том, как ты получаешь объект headers. Удобно, что один и тот же auth-инстанс работает на всех слоях.

Email с подтверждением

Better Auth просит callback, который умеет отправлять письмо. У меня обычно это Resend или Postmark.

emailAndPassword: {
  enabled: true,
  requireEmailVerification: true,
  sendResetPassword: async ({ user, url }) => {
    await sendMail({
      to: user.email,
      subject: 'Сброс пароля',
      html: `<p><a href="${url}">Сбросить пароль</a></p>`,
    });
  },
},
emailVerification: {
  sendVerificationEmail: async ({ user, url }) => {
    await sendMail({
      to: user.email,
      subject: 'Подтвердите почту',
      html: `<p><a href="${url}">Подтвердить адрес</a></p>`,
    });
  },
},

URL Better Auth собирает сам, тебе остаётся положить его в письмо.

OAuth Google: что обязательно проверить

Самая частая ошибка — несовпадение redirect URI. У Better Auth он формируется как {BETTER_AUTH_URL}/api/auth/callback/google. Этот URL надо добавить в OAuth Client консоли Google. Если деплоишься на Vercel, не забудь добавить и preview-домены, иначе превью будет падать.

Второй момент — scope. По умолчанию подтягиваются email и profile. Если хочешь дополнительные данные (например, refresh token из Google) — указывай явно:

socialProviders: {
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    accessType: 'offline',
    prompt: 'consent',
  },
},

Rate limiting

Включаю всегда. По умолчанию используется in-memory store, для сервиса с одним инстансом этого хватает. На multi-instance — указываю Redis-стор.

rateLimit: {
  enabled: true,
  window: 60,
  max: 30,
  // storage: redis(...) — для multi-instance
},

30 запросов в минуту с одного IP к auth-эндпоинтам — нормальный потолок. На /sign-in более строго — настраивается отдельной опцией customRules.

Что мониторить в проде

  • Размер таблицы session. Если ты не подчищаешь истёкшие сессии — она будет расти.
  • Количество ошибок 401 на endpoint-ах /api/*. Скачок — обычно знак, что куки не доходят (не тот домен, не тот SameSite).
  • Очередь email. Если письма верификации не уходят — пользователи застревают на первом шаге.

Шпаргалка по граблям

  • В Next 14 на App Router — обязательно export const dynamic = 'force-dynamic' на странице, читающей сессию. Без этого Server Component кэшируется и сессия отстаёт.
  • На монорепе с несколькими доменами — cookieOptions.domain и общий префикс куки. Без этого один SSO между сабдоменами не получится.
  • BETTER_AUTH_URL на проде — это публичный URL, не localhost и не контейнерное имя. Звучит банально, но эта ошибка нашлась у меня дважды.

Этот сетап стабильный. Логин email/пароль, Google OAuth, серверные сессии в Postgres, email-верификация и rate limiting — всё с минимумом кода. Дальше можно подключать плагины: organizations, magic-link, passkeys, 2FA. Но базовый набор у меня обычно живёт без изменений месяцами.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.