Skip to content

Every app needs to know who is calling it. Identity flows through every entry point we have: SSR and server functions in TanStack Start (ADR-0002), and the API server routes at /api/* (ADR-0036) for external clients. Rate limiting (ADR-0010) keys per-user and per-token limits on this identity, which is why auth is decided first.

Requirements:

  • Runs inside our container (ADR-0003) — no extra service to operate unless it pays for itself
  • User and session data in our Postgres (ADR-0007), in tables we own, migrated by Drizzle Kit like everything else (ADR-0008)
  • Cookie sessions for our UI, tokens for API clients — browsers and non-browser consumers both get a first-class path
  • First-class TanStack Start integration — not a Next.js library we adapt
  • Growth path without a rewrite: OAuth providers, 2FA, passkeys, organizations, SSO as products need them
  • No per-user pricing — auth cost should not scale with our success

Framework-agnostic TypeScript auth library. Runs in-process, stores everything in our database through an adapter, extends through plugins.

Pros

  • Self-hosted library: runs in our Bun container (ADR-0035), no external service, no per-user fees
  • Drizzle adapter (@better-auth/drizzle-adapter, provider: "pg") declares support for both Drizzle 0.45 and the v1 RC (ADR-0008); its CLI generates the auth tables as a Drizzle schema, so auth migrations go through Drizzle Kit with the rest
  • Official TanStack Start integration (tanstackStartCookies() plugin) and Hono integration (auth.handler(c.req.raw))
  • Plugins cover the growth path: 2FA, passkeys, magic links, organizations, SSO, bearer tokens and API keys for non-browser clients
  • Built-in rate limiter on auth endpoints, on by default in production
  • MIT-licensed; the team also maintains Auth.js and recommends Better Auth for new projects

Cons

  • Young project with a fast release cadence; it has shipped security advisories (we patch on release, not on a schedule)
  • Acquired by Vercel (2026-07-07). Stays MIT and keeps its contribution model, but the roadmap now sits inside the platform company we declined as a deploy target (ADR-0003)
  • The adapter’s joins docs only show Drizzle 0.x relations(), not v1’s defineRelations() — joins on the v1 RC are unverified
  • We own the auth security surface: config, secrets, patching

The long-standing open-source option, Next.js-first.

Pros

  • Huge install base, many provider integrations

Cons

  • Now maintained by the Better Auth team on a security-patches-and-urgent-fixes basis; they recommend Better Auth for new projects
  • Next.js-shaped; TanStack Start and Hono are second-class
  • Weak built-in story for 2FA, passkeys, organizations

3. Managed auth (Clerk, WorkOS AuthKit, Auth0)

Section titled “3. Managed auth (Clerk, WorkOS AuthKit, Auth0)”

Hosted identity with drop-in UI components.

Pros

  • Zero auth ops; hosted UI, MFA, enterprise SSO and compliance paperwork included
  • Security patching is the vendor’s job

Cons

  • Per-user (MAU) pricing that scales with success
  • User data lives outside our Postgres — joins, exports, and migrations go through a vendor API
  • Lock-in and an external network dependency on every sign-in, against the control we chose in ADR-0003

4. Self-hosted identity provider (Keycloak, Zitadel)

Section titled “4. Self-hosted identity provider (Keycloak, Zitadel)”

A standalone OIDC server that our apps delegate to.

Pros

  • Standards-based (OIDC/SAML); one login across many products
  • Mature admin UI, enterprise federation

Cons

  • A second service per environment to run, upgrade, back up, and secure
  • Heavy for a single app; OIDC redirects for every product, even a small one
  • User data split between the IdP’s database and ours

5. Roll our own (Lucia-style, with Oslo + Arctic)

Section titled “5. Roll our own (Lucia-style, with Oslo + Arctic)”

Sessions, password hashing, and OAuth built from small primitives. Lucia is now a learning resource, not a library.

Pros

  • Full control, minimal dependencies, nothing hidden

Cons

  • We write and review every security-critical line: session handling, CSRF, OAuth flows, password resets, 2FA
  • Every product’s growth feature (passkeys, orgs, SSO) becomes our project

Better Auth is the company-standard authentication library for all new apps.

Concretely:

  • Version: latest stable, 1.7.x (1.7.11 at time of writing). New apps start on npm’s latest tag — never beta or rc (1.8 is in beta). Four packages move together at one version: better-auth, @better-auth/drizzle-adapter, @better-auth/redis-storage, and the CLI, which is now the auth package (@better-auth/cli is stale at 1.4 — don’t use it)
  • Mounted by a Start server route at /api/auth/*: routes/api/auth/$.ts hands GET and POST to auth.handler(request), Better Auth’s documented TanStack Start setup (ADR-0036). (Amended 2026-11-13: this ADR first mounted it in Hono; ADR-0036 replaced Hono with Start’s server routes.)
  • Database: Drizzle adapter with provider: "pg". Auth tables are generated into our Drizzle schema by the CLI (auth as a devDependency at the same version as better-auth, run as bunx auth generate, not auth@latest) and migrated by Drizzle Kit — one migration pipeline. Leave advanced.database.joins off until it is verified against Drizzle v1’s defineRelations()
  • UI sessions: httpOnly session cookies. tanstackStartCookies() is the last plugin in the array; SSR and server functions read the session with auth.api.getSession({ headers }) in Start middleware
  • Session lookups hit Valkey, not Postgres. Better Auth’s secondaryStorage uses the official @better-auth/redis-storage package on the shared ioredis client and Valkey instance from ADR-0010. session.storeSessionInDatabase: true writes every session to Postgres too: Valkey is the fast path, Postgres the durable record. Lookups check Valkey first and fall back to Postgres on a miss, so a Valkey restart costs latency, not a mass logout. Revocation deletes from both
  • Not cookieCache: it skips the lookup entirely, but a revoked session stays valid until the cached cookie expires. Valkey keeps revocation immediate
  • API clients: non-browser consumers use Better Auth’s bearer or API-key plugins, chosen per product. API routes read only the Authorization header, never the session cookie (ADR-0036)
  • Plugins are opt-in per product — the default install is email/password plus whatever OAuth providers the product needs
  • Passwords are hashed with Bun.password (argon2id, Bun’s default) through emailAndPassword.password.hash and verify, instead of Better Auth’s default scrypt (ADR-0035). OWASP lists argon2id first; scrypt is its fallback. (Amended 2026-11-13.)
  • Security advisories are patched on release, not batched with routine upgrades

The deciding factors: it runs in our container and keeps every user and session row in our own Postgres, it plugs into Drizzle Kit and TanStack Start without glue code, and its plugins cover the growth path without changing libraries. We accept owning the security surface and the Vercel stewardship risk — the MIT license and our own tables keep the exit open.

Easier:

  • One identity across SSR, server functions, and /api/* — the key rate limiting needs (ADR-0010)
  • User data is ordinary Postgres tables: joins, reporting, exports, and backups need no vendor API
  • Adding 2FA, passkeys, or organizations to a product is a plugin and a migration, not a migration to a new library
  • The per-request session check is a Valkey read, not a Postgres query

Harder:

  • We own auth security: secret rotation, cookie settings, and prompt patching on every advisory
  • Better Auth’s built-in limiter is turned off (rateLimit: { enabled: false }); ADR-0010’s limiter covers /api/auth/*, and its credential-endpoint list must track Better Auth upgrades
  • Upgrades touch the auth schema; bump all four Better Auth packages together, regenerate the schema, and review the Drizzle migration on every Better Auth upgrade
  • Valkey now holds live session tokens. Its password and network isolation (ADR-0010) are security controls: a reachable Valkey means hijackable sessions
  • With secondaryStorage set, verification records (email verification, magic links, OTPs, password resets) and OAuth state live only in Valkey (verification.storeInDatabase defaults to false). A Valkey restart voids pending links and in-flight OAuth sign-ins; users request them again. Accepted — they are short-lived by design
  • @better-auth/redis-storage requires ioredis 5.x; stay on 5.x until its peer range allows 6
  • Two fast-moving dependencies meet here (Better Auth’s release cadence, Drizzle v1 RC); test sign-in flows on every upgrade of either

Given up: managed auth’s zero-ops security and hosted UI, and a central IdP’s single sign-on across products. Accepted — control of our user data and no per-user pricing matter more at our scale; a product that needs enterprise SSO can add Better Auth’s SSO plugin or write an exception ADR.