Skip to content

Self-hosted auth with better-auth + Drizzle + RBAC

For apps that don't want a separate IdP service (no Logto, no Keycloak), better-auth owns the auth flows directly against your own database. Password hashing, OAuth providers, sessions, email verification, password reset, 2FA, organizations — all in one library that writes to your Drizzle/Prisma DB.

This recipe composes three things we already shipped:

  • @nwire/data-drizzle for the database
  • better-auth for the auth flows
  • @nwire/auth-better-auth to expose better-auth as our IdpAdapter
  • @nwire/rbac for permissions

1. Install

bash
pnpm add @nwire/auth @nwire/auth-better-auth @nwire/rbac \
         @nwire/data-drizzle drizzle-orm pg better-auth
pnpm add -D drizzle-kit @types/pg

2. Configure better-auth

better-auth writes its tables into your DB. Generate the schema once with its CLI:

bash
npx @better-auth/cli generate --output src/db/auth-schema.ts

Then wire it:

ts
// src/auth/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import { db } from "../db/index.js";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    minPasswordLength: 8,
  },
  socialProviders: {
    github: {
      clientId:     process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  plugins: [organization()],
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) => {
      // Use @nwire/mail (Phase 65) — for now, console.log.
      console.log(`Verify: ${url}`);
    },
  },
});

3. Wire it as an IdpAdapter

ts
// src/app.ts
import { defineApp } from "@nwire/forge";
import { sql }       from "drizzle-orm";
import { identityPlugin, SignIn, SignOut, Register, Refresh, Me,
         RequestPasswordReset, ResetPassword, VerifyEmail } from "@nwire/auth";
import { betterAuthAdapter } from "@nwire/auth-better-auth";
import { rbacPlugin, defineAbility } from "@nwire/rbac";
import { drizzleProvider }    from "@nwire/data-drizzle";
import { rest, http, post }    from "@nwire/http";
import { db, pool }            from "./db/index.js";
import { auth }                from "./auth/index.js";
import { postsModule }         from "./posts/index.js";

const buildAbility = defineAbility((user, { allow }) => {
  if (!user) return;
  if (user.roles?.includes("admin")) { allow("manage", "all"); return; }
  allow("read",   "Post");
  allow("create", "Post");
  allow("update", "Post", { authorId: user.id });
  allow("delete", "Post", { authorId: user.id });
});

export const app = defineApp("my-app", {
  modules: [postsModule],
  plugins: [
    drizzlePlugin({
      db,
      close: () => pool.end(),
      check: (d) => d.execute(sql`SELECT 1`),
    }),
    identityPlugin({ adapter: betterAuthAdapter({ auth }) }),
    rbacPlugin({ buildAbility }),
  ],
});

// Wire the canonical auth actions onto HTTP. identityPlugin registers
// SignIn / SignOut / Register / Refresh / Me / RequestPasswordReset /
// ResetPassword / VerifyEmail as actions; here we expose them as routes.
await httpInterface({
  prefix: "/api",
  port:   Number(process.env.PORT ?? 3000),
})
  .wire(app)
  .wire(post("/auth/sign-in"),         ({ input }) => app.dispatch("SignIn", input))
  .wire(post("/auth/sign-out"),        ({ input }) => app.dispatch("SignOut", input))
  .wire(post("/auth/register"),        ({ input }) => app.dispatch("Register", input))
  .wire(post("/auth/refresh"),         ({ input }) => app.dispatch("Refresh", input))
  .wire(post("/auth/me"),              ({ input }) => app.dispatch("Me", input))
  .wire(post("/auth/forgot-password"), ({ input }) => app.dispatch("RequestPasswordReset", input))
  .wire(post("/auth/reset-password"),  ({ input }) => app.dispatch("ResetPassword", input))
  .wire(post("/auth/verify-email"),    ({ input }) => app.dispatch("VerifyEmail", input))
  .run();

That's the whole composition. Sign-in, registration, password reset, 2FA, email verification, OAuth — all reachable through the canonical SignIn / SignOut / Register / Refresh / Me / RequestPasswordReset / ResetPassword / VerifyEmail actions that ship with @nwire/auth.

4. Frontend usage

ts
// Sign-in
const res = await fetch("/api/auth/sign-in", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "alice@example.com", password: "secret123" }),
});
const { user, tokens } = (await res.json());

// Subsequent requests
await fetch("/api/posts", {
  headers: { Authorization: `Bearer ${tokens.accessToken}` },
});

5. The decision: Logto vs better-auth

Both adapters expose the SAME canonical resolvers (SignIn, SignOut, etc.) and the SAME User shape, so swapping one for the other is a config change — your handler code never touches IdP specifics.

Logtobetter-auth
ArchitectureSeparate service (own DB + admin UI)Library in your app (your DB)
User databaseLogto owns itYou own it (Drizzle/Prisma)
Admin UIPolished, includedDIY
Sign-in flowRedirect to hosted UIDirect POST to your API
OAuth providersAll major + extensibleCommon set built-in
MFA, passkeys, magic links✅ included✅ included
Per-tenant isolation✅ nativePlugin (organization)
Audit log✅ admin consoleDIY
Best for…Multiple apps sharing identity; need polished UX without writing it; complianceSingle-app SaaS; want everything in one DB; can build your own auth UI

Heuristic: if you'd rather configure auth in an admin console, pick Logto. If you'd rather configure auth in TypeScript, pick better-auth.

6. Compose with RBAC

Both adapters populate User.roles (and optionally User.scopes). Your CASL defineAbility rule is identical regardless of which IdP signed the token:

ts
const buildAbility = defineAbility((user, { allow }) => {
  if (user?.roles?.includes("admin")) allow("manage", "all");
  if (user?.scopes?.includes("write:posts")) allow("create", "Post");
});

That's the whole point of the contract layer — IdPs swap, ability rules stay.

MIT licensed.