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/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/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 }) => {
// Wire @nwire/mail here; for now, console.log.
console.log(`Verify: ${url}`);
},
},
});3. Wire it as an IdpAdapter
// src/app.ts
import { createApp } from "@nwire/app";
import { endpoint } from "@nwire/endpoint";
import { sql } from "drizzle-orm";
import { authWires, identityPlugin } from "@nwire/auth";
import { betterAuthAdapter } from "@nwire/auth-better-auth";
import { rbacPlugin, defineAbility } from "@nwire/rbac";
import { drizzlePlugin } from "@nwire/drizzle";
import { httpKoa } from "@nwire/koa";
import { db, pool } from "./db/index.js";
import { auth } from "./auth/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 = createApp({
appName: "my-app",
plugins: [
drizzlePlugin({
db,
close: () => pool.end(),
check: (d) => d.execute(sql`SELECT 1`),
}),
identityPlugin({ adapter: betterAuthAdapter({ auth }) }),
rbacPlugin({ buildAbility }),
],
});
// Canonical /auth/* routes — authWires() returns binding+handler pairs
// for sign-in / sign-out / register / refresh / me / forgot-password /
// reset-password / verify-email.
for (const { binding, handler } of authWires()) {
app.wire(binding, handler);
}
await app.start();
await endpoint("my-app", { port: Number(process.env.PORT ?? 3000) })
.use(httpKoa({ prefix: "/api" }))
.mount(app)
.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.