Add-on — Identity & Auth

The Clerk-style multi-tenant identity layer absorbed from extrastate (better-auth + Drizzle) — orgs/passkey/2FA/JWT — where the active organization is the canonical tenantId and the JWKS verify chokepoint feeds withTenant / Postgres RLS.

Add-on — Identity & Auth

A multi-tenant identity layer absorbed from extrastate, built on better-auth (MIT, self-hosted on our own Postgres — no third-party auth service) + Drizzle. The goal: hostssh offers a Clerk-style auth layer (orgs, members, invites, passkeys, 2FA, OAuth, M2M API keys, JWT/JWKS) where the active organization is the canonical tenantId every other add-on already scopes by, and where verifying a request is the single chokepoint that makes tenant isolation trustworthy.

  • id / entitlement: identity — active.
  • Code: lib/addons/identity/. Import from @/lib/addons/identity.
  • Schema: the better-auth Drizzle schema, created by migration 0015-identity (applied as the owner role via MIGRATIONS_DATABASE_URL).

The permission / tenancy model

organization.id (better-auth) is the tenantId the add-ons take and the hostssh.org_id RLS GUC that withTenant sets. So the model is: authenticate a request → resolve its active org → open a tenant-scoped transaction → Postgres RLS enforces isolation. The verified identity is a dependency-free contract (types.ts):

interface Identity {
  userId: string          // better-auth user id (JWT `sub`)
  tenancyId: string       // the active organization id — the canonical tenant id
  organizationId: string | null
  scopes: string[]        // granted scopes/permissions from the token
}

The verify chokepoint (what the barrel actually exports)

verify.ts is the shipped, dependency-free verifier. createIdentityVerifier(key, { issuer?, audience? }) returns an IdentityVerifier with:

  • verifyToken(token): Promise<Identity> — verifies a raw JWT.
  • verifyRequest(req): Promise<Identity> — pulls the Authorization: Bearer <token> and verifies it.

It verifies against the issuer's JWKS with createRemoteJWKSet + jwtVerify from jose, then resolves { userId, tenancyId, organizationId, scopes } from the payload (sub + activeOrganizationId/org). It fails closed — a missing subject, a missing active organization, a bad signature, or an expired token all throw. The env-configured default wrappers verifyToken / verifyRequest use a lazily-built verifier over HOSTSSH_AUTH_JWKS_URL.

Run verify server-side in route handlers / the data layer — never in middleware (CVE-2025-29927: middleware is not a security boundary).

The RLS bridge is withIdentityTenant(identity, fn) (tenant.ts) → withTenant(identity.tenancyId, …) from lib/rls/tenant, which opens the tenant-scoped txn so Postgres RLS enforces isolation. Verify + withIdentityTenant is the one chokepoint that makes every tenant-scoped add-on (and RLS) trustworthy.

The barrel index.ts exports exactly createIdentityVerifier, verifyToken, verifyRequest, __resetIdentityVerifier, withIdentityTenant, and the Identity / IdentityVerifier / VerifyOptions types — not the better-auth server instance (that is mounted via the route below, not imported from the barrel).

The better-auth server (specified; stood up behind the route)

auth.ts is the hostssh better-auth instance (appName: 'hostssh') on the Drizzle adapter (db.ts, postgres.js pool, max: 5). Enabled: email/password, optional Google (when GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET are set), and the plugins organization, passkey, twoFactor, admin, apiKey, and jwt (which serves /api/auth/jwks). Every route is mounted at app/api/auth/[...all]/route.ts, lazily, so a build never constructs auth.

The schema (schema.ts) is CLI-generated (authoritative) — 11 tables: user, session, account, verification, organization, member, invitation, passkey, two_factor, apikey, jwks. Regenerate SQL from schema.ts via drizzle.config.ts.

Runtime config

Env varPurpose
DATABASE_URLthe least-privilege hostssh role (RLS-enforced), with migration 0015-identity applied.
HOSTSSH_AUTH_URLthe app's base URL (better-auth baseURL).
HOSTSSH_AUTH_SECRETauth secret (falls back to AUTH_SECRET).
HOSTSSH_AUTH_JWKS_URL<base>/api/auth/jwks so verify.ts resolves issued tokens.
HOSTSSH_AUTH_ISSUER / HOSTSSH_AUTH_AUDIENCEoptional JWT iss / aud claims the verifier enforces.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECREToptional, to enable Google sign-in.

Honest status notes

  • What's verified offline: the auth config constructs (the better-auth CLI loaded it to generate the schema), the schema is authoritative, types check, migration 0015 validates, and verify.ts round-trips (sign → verify → tenancy / fail-closed) in unit tests. What still needs a live Postgres: the end-to-end smoke (apply 0015, set HOSTSSH_AUTH_*, sign-up → issue JWT → verify round-trip).
  • jose version: the shipped verify chokepoint runs on the backbone's jose (verify.ts uses createRemoteJWKSet / jwtVerify, whose API is stable across v5→v6). better-auth requires jose@^6, which is already pinned (package.jsonjose ^6.2.3), so that gate is satisfied. Treat verify.ts + tenant.ts as the currently-exported surface and auth.ts as the better-auth server behind the /api/auth/* route.
  • How it ties in later: better-auth's apiKey plugin can supersede the hand-rolled hs_ / webhook keys for one consistent credential model. better-auth is MIT and self-hosted — no third-party service.