Auth — better-auth
When you don't want to run a separate identity service, better-auth handles auth flows directly against your own database — password hashing, OAuth providers, sessions, email verification, password reset, 2FA, organisations. Nwire wraps it in the IdpAdapter contract so the rest of the app reads identity from envelope.user the same way it would with Logto.
Four packages compose:
@nwire/drizzle— the database- better-auth — the auth flows
@nwire/auth-better-auth— adapter that exposes better-auth through Nwire'sIdpAdapter@nwire/rbac— permissions on top of the resulting user
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 }) => {
// Use @nwire/mail — for now, console.log.
console.log(`Verify: ${url}`);
},
},
});3. Wire it as an IdpAdapter
// src/app.ts
import { createApp } from "@nwire/app";
import { sql } from "drizzle-orm";
import { identityPlugin } from "@nwire/auth";
import { betterAuthAdapter } from "@nwire/auth-better-auth";
import { rbacPlugin, defineAbility } from "@nwire/rbac";
import { drizzlePlugin } from "@nwire/drizzle";
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 }),
],
});
// Wire the canonical auth routes — authWires() returns the {binding,handler}
// pairs for sign-in / sign-out / register / refresh / me / forgot-password /
// reset-password / verify-email all at once.
import { authWires } from "@nwire/auth";
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";
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.