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-drizzlefor the database- better-auth for the auth flows
@nwire/auth-better-authto expose better-auth as ourIdpAdapter@nwire/rbacfor permissions
1. Install
pnpm add @nwire/auth @nwire/auth-better-auth @nwire/rbac \
@nwire/data-drizzle drizzle-orm pg better-auth
pnpm add -D drizzle-kit @types/pg2. Configure better-auth
better-auth writes its tables into your DB. Generate the schema once with its CLI:
npx @better-auth/cli generate --output src/db/auth-schema.tsThen wire it:
// 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
// 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
// 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.
| Logto | better-auth | |
|---|---|---|
| Architecture | Separate service (own DB + admin UI) | Library in your app (your DB) |
| User database | Logto owns it | You own it (Drizzle/Prisma) |
| Admin UI | Polished, included | DIY |
| Sign-in flow | Redirect to hosted UI | Direct POST to your API |
| OAuth providers | All major + extensible | Common set built-in |
| MFA, passkeys, magic links | ✅ included | ✅ included |
| Per-tenant isolation | ✅ native | Plugin (organization) |
| Audit log | ✅ admin console | DIY |
| Best for… | Multiple apps sharing identity; need polished UX without writing it; compliance | Single-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:
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.