ADR-0009 Auth
Company standard authentication — Better Auth, self-hosted in the app, sessions in our Postgres.
- Decision
- Better Auth 1.7Store & protect
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Builds on
Context
Section titled “Context”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
Options considered
Section titled “Options considered”1. Better Auth
Section titled “1. Better Auth”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’sdefineRelations()— joins on the v1 RC are unverified - We own the auth security surface: config, secrets, patching
2. Auth.js (NextAuth.js)
Section titled “2. Auth.js (NextAuth.js)”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
Decision
Section titled “Decision”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
latesttag — neverbetaorrc(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 theauthpackage (@better-auth/cliis stale at 1.4 — don’t use it) - Mounted by a Start server route at
/api/auth/*:routes/api/auth/$.tshandsGETandPOSTtoauth.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 (authas a devDependency at the same version asbetter-auth, run asbunx auth generate, notauth@latest) and migrated by Drizzle Kit — one migration pipeline. Leaveadvanced.database.joinsoff until it is verified against Drizzle v1’sdefineRelations() - UI sessions: httpOnly session cookies.
tanstackStartCookies()is the last plugin in the array; SSR and server functions read the session withauth.api.getSession({ headers })in Start middleware - Session lookups hit Valkey, not Postgres. Better Auth’s
secondaryStorageuses the official@better-auth/redis-storagepackage on the sharedioredisclient and Valkey instance from ADR-0010.session.storeSessionInDatabase: truewrites 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
Authorizationheader, 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) throughemailAndPassword.password.hashandverify, 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.
Consequences
Section titled “Consequences”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
secondaryStorageset, verification records (email verification, magic links, OTPs, password resets) and OAuth state live only in Valkey (verification.storeInDatabasedefaults tofalse). 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-storagerequiresioredis5.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.

