Auth & sessions

Single auth core for web + Flutter mobile. Mounts on Hono, talks to Drizzle, bridges identity to Postgres RLS.

Phase 1Decision pendingCore dependency
Confirmed by audit (May 26) with caveats:
  • 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_auth and Better-Auth-Flutter-Client both 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

Out (for now)

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).

OptionVerdict
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.
LuciaSkip. Deprecated March 2025, now a "learn auth" guide only.
ClerkSkip. Vendor lock-in, opaque tenancy, awkward in front of RLS.
Supabase AuthSkip. 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

Mandatory. Plain 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)

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)

  1. POST /auth/sign-in/email → response includes set-auth-token header + JWT (/auth/token).
  2. Flutter stores both in flutter_secure_storage.
  3. Every request: Authorization: Bearer <session-token>.
  4. Dio interceptor refreshes via /auth/token on 401 — single-flight, queue concurrent.
  5. revokeSession(token) → all calls 401, device list updates.

Token lifetimes (2026 consensus)

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)

  1. 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
  2. Wire auth.ts in apps/api/src/lib/auth.ts with plugins enabled. Mount on Hono at /auth/*.
  3. Session middleware in apps/api/src/middleware/session.ts that calls auth.api.getSession({ headers }) and stores on context.
  4. RLS middleware as shown above. Apply after session middleware for any route that should be scoped.
  5. SMS OTP integration — implement send131344Otp in packages/integrations (start with mock that logs; real client comes in SMS phase).
  6. Web login + signup pages in apps/web/src/app/(auth)/{login,signup}/page.tsx using betterAuth.createAuthClient.
  7. Flutter spec stub — document the bearer flow in docs/mobile/auth.md for the mobile team; OpenAPI spec auto-includes the auth routes.
  8. RLS smoke test — integration test: user A creates a property, user B (different org) cannot see it via API.

Open questions

Anti-recommendations

Don'tWhy
next-auth v4EOL. v5 (Auth.js) is supported but still Next-shaped.
lucia-authMaintenance-only since March 2025. Now "learning resource".
passportExpress-coupled, callback-era. No Hono story.
iron-session for mobileWeb-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 OTPsWrong tool. SHA-256 + Redis TTL. Reserve argon2id for passwords.
Per-tenant Postgres rolesOperationally impossible past ~50 tenants.
SET (without LOCAL) for RLS contextLeaks across pooled connections.
Refresh tokens in localStorageXSS exfil trivial. httpOnly cookie for browser, flutter_secure_storage for mobile.

Sources