Auth & sessions
Single auth core for web + Flutter mobile. Mounts on Hono, talks to Drizzle, bridges identity to Postgres RLS.
- NextAuth/Auth.js maintainers merged into Better Auth (discussion #13252) — strongest legitimacy signal.
- CVE-2026-32763 (CVSS 8.2) affects ONLY the Kysely adapter via transitive
kysely@0.28.11. We use Drizzle adapter — unaffected. (tracking) - Pin Drizzle 0.x until better-auth ships Drizzle 1.0 RC support (#7691, #6766).
- Flutter SDK is not production-ready.
flutter_better_authandBetter-Auth-Flutter-Clientboth note "still under development, not fully tested." Wrap bearer plugin manually with Dio + flutter_secure_storage; treat any Flutter SDK as reference only. - Use our own SMS sender (131344/Mocean). Don't use better-auth's managed SMS (Pro-tier only, useless for MN gateways).
Scope
In
- Email + password (owner-side users)
- Phone + SMS OTP (tenant signup via 131344)
- JWT bearer (mobile)
- httpOnly cookie session (web)
- Refresh / rotation, 5-device cap
- Roles: Tenant, Owner-Accountant, Owner-Director, Employee, Admin
- Organization = PropertyOwner; users belong to one org (admin = global)
- Password reset, email verification, 2FA opt-in
- Bridge to Postgres RLS via
SET LOCALGUC per request
Out (for now)
- Social OAuth (Google/Apple) — add later if user demand emerges
- SAML / enterprise SSO — none of our customers need it
- Per-permission ACL — coarse role + property scope is enough
Library pick: better-auth v1.4+
Framework-agnostic, Drizzle adapter, first-class plugins for everything above. Mounts on any Hono handler via auth.handler(req).
| Option | Verdict |
|---|---|
| better-auth v1.4+ | Pick. Framework-agnostic, Drizzle adapter, plugins (bearer, jwt, organization, phone-number, multi-session, two-factor, admin). MIT, ~20k stars, weekly releases. |
| Auth.js v5 (NextAuth) | Skip. Next-shaped. Mobile JWT is a hack. Multi-tenant org is DIY. |
| Lucia | Skip. Deprecated March 2025, now a "learn auth" guide only. |
| Clerk | Skip. Vendor lock-in, opaque tenancy, awkward in front of RLS. |
| Supabase Auth | Skip. Only works if you're on Supabase. |
Roll-your-own (jose + argon2) | Skip unless better-auth blocks you. You'd rebuild the same primitives. |
Install + wire-up
pnpm add better-auth pg
pnpm --filter @spacehub/db add better-auth # for adapter types
npx @better-auth/cli generate # emits Drizzle schema for user/session/account/verification
pnpm db:generate && pnpm db:migrate
// apps/api/src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import {
bearer, jwt, organization,
phoneNumber, multiSession, admin
} from "better-auth/plugins";
import { db } from "./db";
import { send131344Otp } from "@spacehub/integrations/sms";
export const auth = betterAuth({
database: drizzleAdapter(db(), { provider: "pg" }),
emailAndPassword: { enabled: true, requireEmailVerification: true },
plugins: [
bearer(), // mobile: Authorization: Bearer <token>
jwt({ jwt: { expirationTime: "15m" } }),
organization(), // PropertyOwner = organization
phoneNumber({
sendOTP: send131344Otp,
signUpOnVerification: {
getTempEmail: (p) => `${p}@phone.spacehub.mn`,
},
}),
multiSession({ maximumSessions: 5 }),
admin({ adminRoles: ["admin"] }),
],
});
// mount on Hono
app.on(["POST", "GET"], "/auth/*", (c) => auth.handler(c.req.raw));
RLS bridging — SET LOCAL in a per-request transaction
SET leaks across pooled connections (security incident waiting to happen). SET LOCAL + set_config(..., true) only live for the current transaction. Without BEGIN, the GUC never applies and RLS sees NULL — i.e. anonymous.
// apps/api/src/middleware/rls.ts
import { createMiddleware } from "hono/factory";
import { sql } from "@spacehub/db";
import { db } from "../lib/db";
export const withRls = createMiddleware(async (c, next) => {
const session = c.get("session"); // populated by better-auth middleware
if (!session) {
return c.json({ error: { code: "UNAUTHENTICATED" } }, 401);
}
await db().transaction(async (tx) => {
await tx.execute(sql`
select set_config('app.user_id', ${session.user.id}, true),
set_config('app.owner_id', ${session.activeOrganizationId ?? ""}, true),
set_config('app.role', ${session.user.role ?? "tenant"}, true)
`);
c.set("tx", tx);
await next();
});
});
Route handlers use c.get("tx") instead of the global db() for any query that should honor RLS. RLS policies are declared inline on each table — see Identity & ACL.
Pooler caveat
If you ever front Postgres with PgBouncer in transaction mode (Supavisor, Neon pooler), disable prepared statements (postgres({ prepare: false })) and never use SET outside BEGIN. SET LOCAL inside an explicit transaction is the only safe form; COMMIT ends the session and the conn is reused cleanly.
Perf cost
One extra round-trip per request for BEGIN + set_config. Sub-ms on local network. The real cost is policy authoring — wrap subqueries in (select ...) for per-statement caching, and always index the FK referenced in the policy (e.g. owner_id). Per Supabase benchmarks, that's the difference between 5 ms and 500 ms.
SMS OTP — port 131344 behind phoneNumber plugin
// packages/integrations/src/sms/131344.ts
export async function send131344Otp({
phoneNumber, code
}: { phoneNumber: string; code: string }) {
await sms131344.send(
phoneNumber,
`Spacehub код: ${code}. 5 мин дотор оруулна уу.`
);
}
Rules (battle-tested)
- Storage:
SHA-256(otp + phone + purpose)in Redis withEX 300. Never plaintext. Never bcrypt — overkill for 6-digit codes that already expire. - Atomic consume:
GETDELon verify — prevents parallel-replay. - Action binding: key =
otp:{purpose}:{phone},purpose ∈ {login, signup, reset}. A login OTP must not unlock password reset. - Rate limits (3 dimensions, Redis token bucket via
INCR+EXPIRE): per-phone 5/h, per-IP 10/h, per-phone 1/30s. - AIT/pump fraud: block premium-rate prefixes; cap unverified phones per IP/day.
Session storage + token lifetimes
Web
better-auth defaults — opaque session id in httpOnly + SameSite=Lax cookie, server-side session row in Postgres. Rotates on every fresh sign-in event.
Mobile (Flutter)
POST /auth/sign-in/email→ response includesset-auth-tokenheader + JWT (/auth/token).- Flutter stores both in
flutter_secure_storage. - Every request:
Authorization: Bearer <session-token>. - Dio interceptor refreshes via
/auth/tokenon 401 — single-flight, queue concurrent. revokeSession(token)→ all calls 401, device list updates.
Token lifetimes (2026 consensus)
- Access JWT: 15 min, signed RS256. Claims:
sub(userId),org(ownerId),role. FeedsSET LOCAL. - Session token (refresh): 30 days sliding, 7 days absolute idle, 90 days hard absolute. better-auth rotates on each fresh event.
Device tracking + "logout other devices"
multi-session plugin + listDeviceSessions() / revokeSession(token). Each session row has userAgent, ipAddress, createdAt, expiresAt. Add deviceLabel via the adapter's additionalFields for nicer UX ("Galaxy S25, Ulaanbaatar").
API surface
POST /auth/sign-up/email
POST /auth/sign-in/email
POST /auth/sign-out
POST /auth/sign-in/phone-number # send OTP
POST /auth/phone-number/verify # verify OTP, create session
POST /auth/forget-password
POST /auth/reset-password
POST /auth/verify-email
GET /auth/session # current user
GET /auth/list-sessions # device list
POST /auth/revoke-session # logout other device
POST /auth/revoke-sessions # logout everywhere
GET /auth/token # refresh JWT (mobile)
POST /auth/organization/create # create PropertyOwner
POST /auth/organization/invite-member
GET /auth/organization/list-members
POST /auth/organization/update-role
POST /auth/organization/set-active # switch active org (rare; users belong to 1)
Build steps (Phase 1, week 1-2)
- Install + generate schema
pnpm add better-auth pg pnpm --filter @spacehub/db add better-auth npx @better-auth/cli@latest generate --output packages/db/src/schema/auth.ts pnpm db:generate && pnpm db:migrate - Wire
auth.tsinapps/api/src/lib/auth.tswith plugins enabled. Mount on Hono at/auth/*. - Session middleware in
apps/api/src/middleware/session.tsthat callsauth.api.getSession({ headers })and stores on context. - RLS middleware as shown above. Apply after session middleware for any route that should be scoped.
- SMS OTP integration — implement
send131344Otpinpackages/integrations(start with mock that logs; real client comes in SMS phase). - Web login + signup pages in
apps/web/src/app/(auth)/{login,signup}/page.tsxusingbetterAuth.createAuthClient. - Flutter spec stub — document the bearer flow in
docs/mobile/auth.mdfor the mobile team; OpenAPI spec auto-includes the auth routes. - RLS smoke test — integration test: user A creates a property, user B (different org) cannot see it via API.
Open questions
- SSO bridge during strangler period? See strangler page. Recommended: users re-login on v2 (small user base, low friction).
- 2FA mandatory or opt-in? Default to opt-in for v2 launch, mandatory for admin role.
- Phone-only signup for tenants — confirm scope? If most tenants don't have email, phone+OTP becomes the primary path; need UX flow review.
Anti-recommendations
| Don't | Why |
|---|---|
next-auth v4 | EOL. v5 (Auth.js) is supported but still Next-shaped. |
lucia-auth | Maintenance-only since March 2025. Now "learning resource". |
passport | Express-coupled, callback-era. No Hono story. |
iron-session for mobile | Web-only stateless seal. No rotation, no device list. |
jsonwebtoken (npm jwt) | Repeated CVE history (CVE-2022-23529 family). Use jose (better-auth already does). |
bcrypt for OTPs | Wrong tool. SHA-256 + Redis TTL. Reserve argon2id for passwords. |
| Per-tenant Postgres roles | Operationally impossible past ~50 tenants. |
SET (without LOCAL) for RLS context | Leaks across pooled connections. |
Refresh tokens in localStorage | XSS exfil trivial. httpOnly cookie for browser, flutter_secure_storage for mobile. |