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 viaMIGRATIONS_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 theAuthorization: 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 var | Purpose |
|---|---|
DATABASE_URL | the least-privilege hostssh role (RLS-enforced), with migration 0015-identity applied. |
HOSTSSH_AUTH_URL | the app's base URL (better-auth baseURL). |
HOSTSSH_AUTH_SECRET | auth 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_AUDIENCE | optional JWT iss / aud claims the verifier enforces. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | optional, 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
0015validates, andverify.tsround-trips (sign → verify → tenancy / fail-closed) in unit tests. What still needs a live Postgres: the end-to-end smoke (apply0015, setHOSTSSH_AUTH_*, sign-up → issue JWT → verify round-trip). - jose version: the shipped verify chokepoint runs on the backbone's jose (
verify.tsusescreateRemoteJWKSet/jwtVerify, whose API is stable across v5→v6). better-auth requiresjose@^6, which is already pinned (package.json→jose ^6.2.3), so that gate is satisfied. Treatverify.ts+tenant.tsas the currently-exported surface andauth.tsas the better-auth server behind the/api/auth/*route. - How it ties in later: better-auth's
apiKeyplugin can supersede the hand-rolledhs_/ webhook keys for one consistent credential model. better-auth is MIT and self-hosted — no third-party service.