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.

The two-plane model

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.

Tables at a glance

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
twoFactor (TOTP secret + backup codes; users.two_factor_enabled flag)
──────── 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.

Cloud-brokered SSO (handoff)

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.

Workspaces (tenants)

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/*)

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

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

Per-workspace better-auth instance

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.

Admin surface (/api/app-users)

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.

Control-plane auth features

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

Per-workspace config

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

JWT bridge

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 AUTH_SECRET, 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.

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

SDK integration

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.

End-to-end flow example

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.