Skip to content
Auth

Auth planes & workspace end-users

The control-plane vs app-plane split — how admin users (running the dashboard) and workspace end-users (customers of the app built on a workspace) coexist with separate tables, sessions, and APIs.

Backlex is built for two distinct audiences inside the same instance:

  • The team running the dashboard — operators, developers, internal admins. They sign into app.your-backlex.com, configure collections, write permissions, invite teammates.
  • The end-users of whatever product is being built on top of a workspace — the customers of a SaaS, the readers of a CMS, the members of a community. They never see the dashboard. They only ever talk to the workspace’s own auth surface and its data API.

Mixing those two pools into a single user table breaks down fast: admin sign-ups and customer sign-ups have different policies, you need separate suspension states, you don’t want a workspace’s customer to be able to switch into the dashboard with the same cookie, and a workspace built for “1000 customers per tenant” can’t share a row range with the much smaller “5 admins per company” pool.

This page is the single conceptual reference for the split. Each section points at the code rather than restating it, so when a detail changes the doc tells you where to look.

auth.plane (set on c.var.auth by apps/web/src/server/middleware/session.ts) tags every authenticated request with one of two values:

PlaneWhoTables it touchesAuth surfaceCookie / token
"platform"Admins running the dashboardusers, sessions, accounts, verifications, passkeys, twoFactor/api/auth/* (better-auth)better-auth.* cookies + opaque session tokens; PAKs (pak_…) for machine-to-machine
"app"Workspace customersapp_users, app_sessions, app_accounts, app_verifications, external_identities/api/t/<slug>/auth/* (per-workspace better-auth)Authorization: Bearer <access-or-refresh-token>

Both planes share the same tenants table and the same role / permission machinery (roles + permissions rows are tenant-scoped; assignments live in user_roles for platform users and app_user_roles for end-users — see packages/db/src/pg/schema.ts:295 onward).

The plane tag flows from sessionMiddleware into tenantMiddleware into requirePermission. Every downstream check — tenant resolution, role load, condition compilation — can branch on it without re-asking “which kind of user is this”. For example, apps/web/src/server/middleware/tenant.ts:204 pins an app-plane request to the workspace stamped on its session row and ignores any X-Backlex-Tenant header — a customer’s frontend can never accidentally walk into another tenant’s data even if it sends one.

platform pool app (workspace end-user) pool
───────────── ──────────────────────────
users app_users tenant_id, status, email, …
sessions app_sessions tenant_id, user_id, token, …
accounts (OAuth links) app_accounts tenant_id, user_id, provider_id, …
verifications app_verifications tenant_id, identifier, value, expires_at
passkeys app_orgs tenant_id, slug, name, …
twoFactor (TOTP secret + backup codes; app_org_members org_id, app_user_id, role
users.two_factor_enabled flag) app_org_member_roles org_id, app_user_id, role_id
app_org_invites org_id, email, role, token, expires_at
──────── shared by both ────────
tenants (workspaces — id, slug, project, env, …)
tenant_members (which platform user belongs to which workspace)
roles (tenant-scoped or global; `admin` flag = bypass)
permissions (role + collection + action + condition + fields)
external_identities (federated identity ↔ user, with `plane` discriminator)
auth_config (per-workspace provider + policy overrides)
email_config (per-workspace email transport)
saml_providers (per-workspace SAML 2.0 IdPs)
ldap_configs (per-workspace LDAP / AD)

Load-bearing details worth pinning:

  • app_users.email is unique per tenant (app_users_tenant_email_idx, packages/db/src/pg/schema.ts:219). A signup with a@b.co in workspace A is a different identity from the same email in workspace B; they’re not even visible to each other’s better-auth instance. Platform users.email is globally unique.
  • app_sessions.token is the opaque refresh token. The shorter access token (JWT) lives only in memory — see JWT bridge.
  • app_user_roles is parallel to user_roles but keyed by app_users.id. The permission resolver drops any role flagged admin for app-plane callers — workspace end-users can never inherit the admin bypass (packages/db/src/pg/schema.ts:331).
  • external_identities.plane is the discriminator that says whether user_id references users.id (platform) or app_users.id (app). There’s no FK because the referent table depends on the plane — same pattern tenant_members.user_id uses (packages/db/src/pg/schema.ts:1042).
    • Control-plane (admin) SSO uses dedicated tables, not this one. Instance-global admin SAML/LDAP (operators signing into the dashboard) stores its providers + federated links in platform_saml_providers, platform_ldap_config (singleton) and platform_external_identities (a real FK to users, unique on (provider_type, provider_id, subject) — no tenant_id). They were added separately rather than overloading the tenant-scoped external_identities/saml_providers/ldap_configs (whose tenant_id FK to tenants has no instance-global slot). In practice the external_identities.plane = "platform" path is therefore unused — app-plane federation only. See routes/platform-auth.ts and services/platform-sso-provisioning.ts. Runtime caveat: LDAP can’t run on Cloudflare Workers, so on cloud projects only platform SAML is available.

Managed cloud projects can skip per-project IdP setup entirely. The cloud control plane already authenticates operators via org-level SSO; cloud-brokered SSO bridges that into a project’s admin so operators don’t reconfigure SAML in every project.

operator → org SSO at cloud → "Open admin via SSO"
→ cloud mints a short-lived token signed with the project's reportSecret
→ 302 to https://<slug>.backlex.com/api/auth/platform/sso/handoff?token=…
→ proj worker verifies + mints the dashboard session → admin
  • Trust anchor is the per-project reportSecret — the value the cloud generates at provisioning and injects into the worker as CLOUD_REPORT_SECRET (the same secret used for tenant→cloud report signing). Because every project has a different secret, a token minted for project A cannot be verified by project B: the signature is the project scoping. No JWKS / key rotation.
  • Token = b64url(utf8(JSON)).b64url(HMAC-SHA256(body, reportSecret)) — UTF-8-safe so non-Latin1 names survive. Payload carries email, name, subject, aud (the project id, checked against CLOUD_PROJECT_ID), a 45s expiry, and a single-use jti. Minted by the cloud (backlex-cloud .../lib/handoff-token.ts), verified by the proj worker (server/lib/cloud-handoff.ts).
  • Endpoint GET /api/auth/platform/sso/handoff?token=… (routes/platform-auth.ts) — gated on cloudConfigured(env) and PLATFORM_SSO_ENABLED, so it 404s on self-host / non-cloud installs. The jti is recorded once in the verifications table (pbroker-jti:*), so a replay → 401.
  • Provisioning reuses provisionPlatformUser with providerType = "cloud", providerId = "cloud-broker". Brokered operators are not auto-linked to a local password/SSO account and carry no group→role map, so on an already-claimed instance they land as authenticated — never a silent admin escalation.
  • Enablement is auto for Scale+ orgs (gated cloud-side on planFor(org.planId).sso, the same flag that controls whether PLATFORM_SSO_ENABLED is injected). Free/Pro projects get PLATFORM_SSO_ENABLED=false, so the handoff 404s there too.

A workspace is a tenants row. It carries:

  • slug — URL-safe handle. Used in /api/t/<slug>/auth/... and on the admin sidebar tile (packages/db/src/pg/schema.ts:31).
  • project / branch / env — informational tags surfaced in the admin UI (e.g. “default / main / production”). Not load-bearing.
  • A 12-char prefix derived from the tenant id — used as the default physical-table namespace c_<tenantPrefix12>_<slug> so two workspaces never collide when they pick the same collection slug (apps/web/src/server/routes/collections.ts:423). The convention is a default, not an invariant — an adopted collection can wrap any existing table name.

Admin CRUD lives in apps/web/src/server/routes/tenants.ts:

Method + pathPurpose
GET /api/tenantsList workspaces the caller belongs to
POST /api/tenantsCreate a workspace; caller becomes owner + admin role
POST /api/tenants/switchSet the backlex-tenant cookie + persist active_tenant_id
GET /api/tenants/{id}/membersList tenant_members
POST /api/tenants/{id}/members/inviteSend a 7-day invite token (best-effort mail via workspace transport)
DELETE /api/tenants/{id}/members/{memberId}Remove a membership
POST /api/tenants/acceptConsume an invite token, bind to caller, seed RBAC role

/switch is the only endpoint that mutates the active workspace — everything else respects whatever tenantMiddleware resolved (see apps/web/src/server/middleware/tenant.ts:165). Resolution order: X-Backlex-Tenant header → backlex-tenant cookie → users.active_tenant_id → first membership → ensureDefaultTenant.

End-user auth surface (/api/t/:slug/auth/*)

Section titled “End-user auth surface (/api/t/:slug/auth/*)”

Every workspace gets its own complete auth surface — what would be a separate “auth as a service” product if you bolted it on after the fact. The router (apps/web/src/server/routes/tenant-auth.ts) mounts these endpoints under /api/t/<slug>/auth:

Method + pathPurpose
GET /providersPublic discovery — provider list + policy flags for the sign-in screen. No secrets. (tenant-auth.ts:289)
POST /sign-up/email, /sign-in/email, /sign-out, /get-session, …Forwarded to the workspace’s better-auth instance (tenant-auth.ts:738).
POST /sign-in/social, /callback/<provider>Social OAuth (Google / GitHub / Apple) — credentials from auth_config first, then env-level.
POST /sign-in/magic-linkOne-time email link. Requires the magic-link plugin enabled for the workspace.
POST /sign-in/email-otp, .../verify-email-otpEmail-code (OTP) sign-in. Requires the email-otp plugin.
GET /saml/<provider-slug>/loginSP-initiated SAML — builds AuthnRequest, persists request id, 302 to IdP. (tenant-auth.ts:304)
POST /saml/<provider-slug>/acsAssertion Consumer — verifies signature + audience + NotOnOrAfter, blocks replays, provisions app_user, issues session, redirects to validated relayState with #token=…. (tenant-auth.ts:339)
GET /saml/<provider-slug>/metadataPublish SP metadata XML for IdP configuration. (tenant-auth.ts:500)
ALL /saml/<provider-slug>/sloSP- or IdP-initiated Single Logout. (tenant-auth.ts:525)
POST /ldap/sign-inLDAP / AD simple bind. Per-(tenant, username, ip) rate-limit + optional domainMatch allow-list. (tenant-auth.ts:569)
POST /token/refreshExchange a refresh token for a fresh access JWT. (tenant-auth.ts:695)

The SAML and LDAP routes are mounted before the better-auth catch-all so they aren’t shadowed by it — see the ordering at tenant-auth.ts:288.

/api/t/* is rate-limited per-IP on the sensitive subpaths (sign-in, sign-up, password reset, magic link, OTP, 2FA) by the same middleware that protects the control-plane router — see app.ts:212.

Abuse protection: per-IP limit + per-account lockout

Section titled “Abuse protection: per-IP limit + per-account lockout”

Two layers guard both planes’ auth surfaces (lib/auth-rate-limit.ts):

  1. Per-IP rate limit — a fixed-window cap per sensitive subpath (sign-up/reset/magic/verify: 5/min; sign-in/OTP: 10/min), keyed by auth:<rule>:<ip>. Backed by a Durable Object on Workers (authoritative across isolates) and an in-memory map elsewhere. A trip is recorded to the audit log as auth.rate_limited.
  2. Per-account lockout — the complement that catches a distributed brute force rotating IPs against one account. authLockoutMiddleware tracks failed password sign-ins (/sign-in/email, HTTP 401) per identifier across all IPs; after AUTH_LOCKOUT_MAX_FAILS (default 8) within AUTH_LOCKOUT_WINDOW_MS (default 15 min) the account is temporarily locked with exponential backoff (AUTH_LOCKOUT_COOLDOWN_MSAUTH_LOCKOUT_MAX_COOLDOWN_MS, default 1 → 15 min). A successful sign-in clears the counter, so a legitimate user is never escalated; the lock affects only new sign-in attempts, not existing sessions. The state machine (lib/lockout-core.ts) is shared by the DO and in-memory backends. The lock edge is audited as auth.login_locked (with the locked identifier). Set AUTH_LOCKOUT_DISABLED=true to turn it off; only password sign-in is gated (OAuth / magic-link / OTP have no password).

Each workspace runs its own better-auth instance. They are not shared. The factory is packages/auth/src/tenant.ts::createTenantAuth, which differs from the control-plane createAuth in two load-bearing ways:

  1. Tenant-scoped adapter wrapper. The Drizzle adapter is wrapped in withTenantScope (packages/auth/src/tenant-adapter.ts), so every read / write is AND-ed with tenant_id = config.tenantId. A query for “user with email a@b.co” in workspace A genuinely cannot see the workspace-B row even if it exists.
  2. Namespaced cookies + base path. cookiePrefix: wo_<slug> and basePath: /api/t/<slug>/auth — so a browser can hold simultaneous sessions for multiple workspaces without them colliding (packages/auth/src/tenant.ts:122).

A databaseHooks.session.create.before hook blocks new sessions for status = "suspended" end-users at the source — even before the session row gets written (packages/auth/src/tenant.ts:177).

The instances are cached per isolate in apps/web/src/server/services/tenant-auth.ts::getTenantAuth. The cache is a 50-entry LRU with a 5-minute TTL plus explicit invalidation through invalidateTenantAuth(tenantId). The admin routes that mutate auth-relevant config — routes/auth-admin.ts (PATCH auth_config), routes/email-config.ts (PUT) and routes/workspace-config.ts — all call it so the next request rebuilds with the new state. TTL is the fallback for changes made on a different isolate (Workers run a fleet of them); explicit invalidation is the fast path for the same isolate that just made the change.

Why one instance per workspace and not one global instance? Because the trusted-origin list, the cookie domain, the email transport, the OAuth client id, the session lifetime, and the social-provider set are all configurable per workspace. You can’t share one better-auth handle across workspaces without giving up that configurability.

Operators manage the active workspace’s end-user pool via apps/web/src/server/routes/app-users.ts. Admin-only (requireAdmin middleware), scoped to whichever workspace tenantMiddleware resolved for the request:

Method + pathPurpose
GET /api/app-usersList end-users with their custom role assignments (app_users.ts:95)
PUT /api/app-users/{id}/rolesReplace role bindings; admin role rejected (app_users.ts:159)
PATCH /api/app-users/{id}Update status (active/suspended) or name; suspending drops sessions (app_users.ts:234)
GET /api/app-users/{id}/sessionsList active app_sessions rows for the user
DELETE /api/app-users/{id}/sessions/{sessionId}Revoke a single session
DELETE /api/app-users/{id}Delete the user + sessions + OAuth accounts + role assignments. Explicit deletes (not FK cascade) because SQLite/D1 don’t enforce FKs by default.
POST /api/app-users/inviteCreate a pending end-user (status: "invited", no credential) + a 7-day token in app_verifications (best-effort mail); optionally binds roles and stamps app_user_id on a person row.

The invitee accepts on the app planePOST /api/t/{slug}/auth/invite/accept with { token, password } sets the credential (better-auth’s scrypt format, so normal email sign-in works from then on), flips the row to active, and returns the standard sign-in token pair.

Self-signups can also link themselves: templates seed per-workspace portalLinks rules into app_settings (see services/portal-links.ts) — on any app-plane user creation (email/social signup, SAML/LDAP provisioning) a person row whose email field matches the new user’s email gets its app_user_id stamped and the rule’s self-service role auto-assigned. Best-effort; never blocks the sign-up.

Distinct from /api/users, which is the control-plane pool. The two never overlap.

End-users can additionally be grouped into organizations — the B2B layer inside a workspace, administered at /api/app-orgs and self-served at /api/t/{slug}/orgs. See Organizations.

The admin better-auth instance (createAuth, packages/auth/src/index.ts) ships a few features beyond email/password + social, all reached under /api/auth/*:

  • Two-factor (TOTP). The twoFactor plugin is loaded unconditionally — it’s inert until a user opts in, so it doesn’t ride AUTH_PLUGINS (like bearer). Users enrol from Account → Security: POST /api/auth/two-factor/enable returns an otpauth:// URI + one-time backup codes; …/verify-totp confirms the first code and flips users.two_factor_enabled. A 2FA-enabled account’s POST /sign-in/email then returns { twoFactorRedirect: true } (no session) until …/verify-totp or …/verify-backup-code succeeds. …/generate-backup-codes re-issues the set. An admin can recover a locked-out user with POST /api/users/{id}/reset-two-factor (clears the secret + backup codes, drops sessions; gated on workspace membership). Magic-link and email-OTP bypass the TOTP gate — better-auth only gates password sign-in — so the admin sign-in screen warns before enabling either while 2FA is available.

  • Email verification. Opt-in via auth_config.policy.requireEmailVerification, and honoured only when a real email transport is configured — the console adapter would log the link instead of delivering it, so the gate stays off there to avoid locking new users out (context.ts computes hasRealEmail). When active, sign-up mails a verification link and password sign-in is rejected with EMAIL_NOT_VERIFIED until it’s clicked; the sign-in screen offers a resend. requireEmailVerification is a construction-time better-auth flag, so a policy change takes effect on the next isolate build.

  • Provider toggle enforcement. The control-plane plugin set is built once per isolate from AUTH_PLUGINS and never rebuilt, so an admin disabling magic / emailOtp in Auth Settings can’t tear the plugin down. The sign-in router (apps/web/src/server/routes/auth.ts) closes the gap at the HTTP edge: a request to a magic-link / email-OTP endpoint whose provider is explicitly enabled: false returns 403 before reaching the handler. (The workspace plane already rebuilds its plugin list from stored config, so it needs no edge gate.)

  • Password sign-in mode. Turning SSO on never turned the password off, so the weakest way in survived every migration. passwordLogin (Settings → Appearance → sign-in screen, or PATCH /api/admin/settings) closes it, per plane:

    ModeAdmin dashboardWorkspace end-users
    enabled (default)password OKpassword OK
    app-onlyrefusedpassword OK
    disabledrefusedrefused

    app-only is the usual shape: staff go through the company IdP while the app’s customers keep the login they signed up with. Enforcement is at the HTTP edge on both mounts (routes/auth.ts for the platform plane, routes/tenant-auth.ts for the app plane) and covers every password path — sign-in/email, sign-up/email, and forget/reset-password — because a sign-up mints a session directly and a reset sets a password the caller then signs in with. Blocking only /sign-in/email would leave the other doors open. /change-password is deliberately not blocked: it needs an existing session, so it is credential management by somebody already inside.

    The setting is instance-global (the tenant_id IS NULL app_settings row) for the same reason the sign-in branding is — the admin sign-in page is reached before any workspace is selected. /api/auth/providers reports the credential provider as enabled: false so the screen stops offering a form the server will refuse; the entry stays in the list because the settings screen has to know the password exists in order to say it is off.

    Lock-out guard. Leaving enabled is refused with 422 unless the admin sign-in screen offers something else — SSO, a passkey, a magic link, or an email code. Without it an admin could turn the password off on an instance with no other provider and the only way back would be a manual DB write. Returning to enabled is never gated. A settings read that fails for any reason resolves to enabled for the same reason.

Two key-value tables hold per-workspace overrides for the auth behaviour the better-auth instance picks up:

  • auth_config (packages/db/src/pg/schema.ts:950). PK is tenant_id, plus the _global sentinel for the instance-wide fallback. Columns: providers (jsonb of { email: { enabled }, github: { enabled, clientId, clientSecretEnc }, … }), policy (jsonb — requireEmailVerification, openSignup), session_lifetime (e.g. 30d / 24h / 90m), redirect_urls (list of allowed callback origins — also folded into the CORS allow-list).
  • email_config (packages/db/src/pg/schema.ts:1131). Same shape — per-workspace email transport. Full reference in API keys, access tokens, email & OAuth.

Resolution order is consistent across both:

workspace row → _global row → env-derived default

apps/web/src/server/services/auth-config.ts::loadAuthConfigRow implements it. A row with provider = "inherit" (email) or with no enabled providers (auth) falls through cleanly to the next level. Read failures (e.g. the table not migrated yet on a brand-new instance) also degrade to null rather than throw — the discovery endpoint must never 500.

resolveAuthSurface builds the public /providers response. It only ever advertises providers the running worker can actually serve — workspace-level opts-in for OAuth need their own clientId + clientSecretEnc; magic-link / email-OTP need to be explicitly enabled and use the deployment’s email adapter (services/auth-config.ts:154).

App-plane sign-ins return a token pair: the long-lived, revocable app_sessions row doubles as the refresh token, and on top of it apps/web/src/server/lib/jwt.ts::signAccessToken mints a short-lived access token (HS256 JWT, 15-minute TTL hardcoded as ACCESS_TOKEN_TTL_SECONDS).

The middleware accepts either shape on Authorization: Bearer (apps/web/src/server/middleware/session.ts:177):

  1. JWT first (fast path — verify with the signing key, no DB hit);
  2. opaque app_sessions lookup as fallback.

The TTL is hardcoded because access-token revocation latency is a property of the design — making it env-tunable invites someone to set it to “24 hours” and accidentally turn revocation off. If you need a different window, edit the constant.

HS256 is fine while backlex is the only thing that reads its own tokens. The moment something else has to — an edge worker gating a route, a partner API, a second service behind the same login — a shared secret is the wrong tool: anything that can verify with it can also mint with it.

Set AUTH_JWT_PRIVATE_KEY to a PKCS#8 PEM and access tokens are signed ES256 (EC P-256) or RS256 (RSA) instead, with the public half served at GET /.well-known/jwks.json — public, CORS-open, cached 5 minutes:

Terminal window
openssl ecparam -name prime256v1 -genkey -noout \
| openssl pkcs8 -topk8 -nocrypt # → AUTH_JWT_PRIVATE_KEY

The kid isn’t configured: it’s the RFC 7638 thumbprint of the public key, so it’s stable per key and changes exactly when the key does. Tokens also carry iss (your APP_URL) so a verifier can pin the issuer.

Verifying elsewhere — no secret, no round-trip per request:

import { createTokenVerifier } from "backlex/token";
const verifier = createTokenVerifier({ url: "https://api.example.com" });
const claims = await verifier.verify(bearerToken); // null when invalid

The verifier caches the JWKS (5 min default) and refetches when it meets an unknown kid, at most every 30s. It rejects HS256 tokens outright — a remote holder of that secret could mint tokens, which defeats the point.

Rollout is non-breaking. Verification dispatches on the token’s own alg into the matching key type (never a public key fed to HMAC — the classic confusion forgery), and HS256 stays accepted, so sessions minted before the switch keep working. HS256 can’t be turned off anyway: the internal detached agent-run token is symmetric by design.

Rotation. Put the new private key in AUTH_JWT_PRIVATE_KEY and the old public key (SPKI PEM) in AUTH_JWT_PUBLIC_KEYS — several may be concatenated. New tokens use the new key; outstanding ones still verify, here and for external verifiers reading the JWKS, until they expire (15 min). Then drop the old key.

A private key that is set but unparseable throws rather than falling back to HS256 — a silent downgrade would leave you believing tokens are asymmetric when they aren’t. Instances with no key configured serve {"keys": []} (not a 404), so a client can detect the mode.

Full reference (response shape, refresh endpoint, trade-offs): API keys, access tokens, email & OAuth.

backlex has two modes, controlled by the workspace option:

import { createClient } from "backlex";
// Admin / control-plane mode — talks to /api/auth/* (better-auth cookies).
const admin = createClient({ url: "https://api.example.com" });
await admin.auth.signIn({ email, password });
await admin.from("posts").list();
// App / workspace mode — talks to /api/t/<slug>/auth/*.
const app = createClient({
url: "https://api.example.com",
workspace: "acme", // tenant slug
token: localStorage.getItem("wks_token") ?? undefined, // restore prior session
});
await app.auth.signUp({ email, password, name });
// → response includes { accessToken, refreshToken, expiresIn, token, user }.
// The SDK captures `token` into memory; subsequent requests send it as
// `Authorization: Bearer <token>`.
localStorage.setItem("wks_token", app.auth.getToken() ?? "");

In app mode:

  • auth.signUp provisions a row in app_users for the named workspace, not in the control-plane users pool.
  • auth.signIn returns the same token pair the LDAP / SAML / email flows return. The SDK captures token (the refresh token) into appToken and replays it on every subsequent call — data and auth.
  • auth.providers() returns the workspace’s public auth surface (/api/t/<slug>/auth/providers) — provider list + policy flags ready to render a sign-in screen.

createClient source: packages/client/src/index.ts.

A SaaS app called “Acme” runs on top of Backlex. The flow from zero to “logged-in customer reading their data”:

1. Operator creates the workspace + a collection (control plane).

Terminal window
# Browser session at app.backlex.example.com — admin cookie.
curl -X POST https://api.backlex.example.com/api/tenants \
-H "content-type: application/json" \
--cookie "$ADMIN_COOKIES" \
-d '{ "name": "Acme", "env": "production" }'
# → { data: { id: "ten_…", slug: "acme", name: "Acme" } }
curl -X POST https://api.backlex.example.com/api/collections \
-H "content-type: application/json" \
-H "X-Backlex-Tenant: acme" \
--cookie "$ADMIN_COOKIES" \
-d '{ "slug": "tasks", "fields": [
{ "name": "title", "type": "text", "required": true },
{ "name": "done", "type": "boolean" }
], "ownerScoped": true }'

2. Operator enables Google + magic-link for Acme.

PATCH /api/admin/auth (route in apps/web/src/server/routes/auth-admin.ts) writes the auth_config row and invalidates the cached getTenantAuth(acme). Next sign-in request through /api/t/acme/auth/* builds a fresh better-auth instance with the new providers.

3. End-user signs up through the customer-facing app.

import { createClient } from "backlex";
const wks = createClient({
url: "https://api.backlex.example.com",
workspace: "acme",
});
const { user, token } = await wks.auth.signUp({
email: "alice@example.com",
password: "correct-horse-battery",
});
// Save the refresh token so the next page load doesn't sign them out.
localStorage.setItem("wks_token", wks.auth.getToken()!);

Under the hood: POST /api/t/acme/auth/sign-up/email → tenant-scoped better-auth writes to app_users with tenant_id = ten_…. Bearer plugin issues a session row in app_sessions. The custom token-pair wrapper around it also issues a 15-minute access JWT.

4. End-user reads / writes their data with the same client.

const tasks = await wks.from("tasks").list();
// → GET /api/items/tasks
// Authorization: Bearer <refresh-or-access-token>
// The `ownerScoped` flag means each app_user only sees their own rows.
await wks.from("tasks").create({ title: "Ship it", done: false });

Every request goes through:

  • sessionMiddleware — bearer token matches an app_sessions row, sets auth.plane = "app", auth.userId, auth.appSessionTenantId.
  • tenantMiddleware — pins the request to appSessionTenantId (ignores any header / cookie). Loads role names from app_user_roles.
  • requirePermission("tasks", "read"|"create") — resolves against permissions for the workspace; ownerScoped injects an owner_id = $user.id condition compiled to a Drizzle SQL fragment.

The same Backlex instance, on the same routes, serves Acme’s internal operators and Alice’s CRUD with no shared identity surface between them.