Skip to content

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's IdpAdapter
  • @nwire/rbac — permissions on top of the resulting user

1. Install

bash
pnpm add @nwire/auth @nwire/auth-better-auth @nwire/rbac \
         @nwire/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 — for now, console.log.
      console.log(`Verify: ${url}`);
    },
  },
});

3. Wire it as an IdpAdapter

ts
// 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

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.