Skip to content

Architecture & Topology

The system is three Workers plus two static SPAs, sharing one Postgres via Hyperdrive (Cloudflare’s connection pooler for Postgres) and one Better Auth instance. The boundaries between them aren’t incidental — they are the security model: tenants and operators never share a session, never share a worker, and never share a cookie domain. Getting the topology right is what makes every later chapter’s isolation guarantees hold, so it’s worth a few minutes up front.

Worker topology
Rendering diagram…

apps/auth holds the single Better Auth instance, factoried per request via createAuth(db, env, ctx, options). It owns the identity tables — users, sessions, accounts, organization, member, invitation, sso_providers, verifications, two_factors, jwks — and exposes an AuthEntrypoint RPC (getSession, handleAuthRequest, getToken, createUser, signInEmail, acceptInvitation, invalidateTenant). It is not publicly reachable; only other workers reach it, over a service binding.

apps/server is the public tenant-facing API on .app.example.com and on tenant custom hostnames. It owns the tenancy module (custom-hostname CRUD), the org-admin SSO-config module, the invitation-acceptance orchestration, and within-org reads of users, audit logs, notifications, and roles. It holds the Cloudflare API token for hostname provisioning, runs the 60-second reconciler cron, and serves the tenant SPA by proxying non-API paths to apps/app through its STATIC_ASSETS binding.

apps/admin is the operator-facing API on admin.example.com. Every request is gated twice: Cloudflare Access verifies the operator’s JWT at the perimeter, then a global_admins table lookup gates at the database. It owns the global_admins table, exposes tenant CRUD and cross-tenant queries, serves apps/admin-ui, and runs a daily inactivity-sweep cron.

apps/app and apps/admin-ui are static-assets workers — Cloudflare’s “Workers Static Assets”, which serves a built SPA’s files (HTML/JS/CSS) straight from the edge with no business logic of its own. apps/app is reachable only through apps/server’s STATIC_ASSETS binding, with not_found_handling: "single-page-application" so client-side routes resolve to index.html. apps/admin-ui is built by Vite and served by apps/admin’s ASSETS binding; it has no separate deployable.

First, a term: a fallback origin is the one zone-level hostname that every tenant custom domain (app.acme.com, dash.bigco.io, …) quietly lands on once Cloudflare for SaaS verifies it. Instead of wiring a route per customer, you point them all at a single origin and let one Worker sort out which tenant each request belongs to.

With that in hand, the zone is configured once, as a runbook: a proxied fallback origin (fallback.example.com) pointed at apps/server, a proxied wildcard (*.app.example.com) at apps/server, a published CNAME target (customers.example.com) that tenants point their custom domains at, the Cloudflare for SaaS fallback origin set at the zone level, and a Cloudflare Access application on admin.example.com.

The tenant worker matches its three hostnames explicitly:

apps/server/wrangler — routes
"routes": [
{ "pattern": "*.app.example.com/*", "zone_name": "example.com" },
{ "pattern": "app.example.com/*", "zone_name": "example.com" },
{ "pattern": "fallback.example.com/*", "zone_name": "example.com" }
]

Custom hostnames like app.acme.com need no per-tenant route entries — they land on the worker through the SaaS zone’s fallback origin. The admin worker, by contrast, binds with custom_domain: true rather than a route pattern. The difference: a route pattern matches traffic on a hostname the zone already owns, while custom_domain: true tells Cloudflare to provision and manage the DNS record and certificate for that exact hostname — which is what lets a single Access application attach cleanly to it:

apps/admin/wrangler — routes
"routes": [{ "pattern": "admin.example.com", "custom_domain": true }]

apps/app has no public routes at all; it’s reached only via the STATIC_ASSETS binding.

Workers talk to each other over typed RPC, not HTTP — the binding is the internal security perimeter, since none of these entrypoints are publicly routable.

FromToEntrypointPurpose
apps/serverapps/authAuthEntrypointAuth proxy + RPC for session/token/user operations
apps/serverapps/app(default fetch)STATIC_ASSETS for non-API paths
apps/authapps/serverApiEntrypointAuth lifecycle hooks + invalidateTenant
apps/adminapps/serverAdminApiEntrypointTenant creation, cross-tenant queries, hostname lifecycle
apps/adminapps/authAuthEntrypointOperator-initiated user creation + accept-invitation

There is deliberately no binding from apps/auth to apps/admin. The admin worker is asymmetric: it fans out to its peers, and they never call it back. Auth-side cache invalidation flows the other way, through apps/auth → apps/server.invalidateTenant(...) on the existing binding.

Five traces make the topology concrete. Start with an ordinary authenticated API call from a tenant:

Tenant API request — GET https://acme.app.example.com/api/users
Rendering diagram…

A first-load on a custom domain shows why the static SPA is served through apps/server rather than directly:

Tenant SPA asset — GET https://app.acme.com/dashboard
Rendering diagram…

SSO sign-in is where the auth proxy earns its keep — it rebuilds a sanitized request before forwarding, and the auth worker validates the provider against the tenant before the OIDC token exchange:

SSO sign-in
Rendering diagram…

Operator-led tenant creation crosses the perimeter into the admin worker, which creates the org via a direct transactional insert and a dual-scope audit:

Operator creates a tenant
Rendering diagram…

Finally, invitation acceptance is the one public route that creates a session. It’s worth reading as an ordered procedure because each step guards the next:

  1. tenantMiddleware resolves the tenant from the host.
  2. Load the invitation and guard it: pending, not expired, org matches the tenant.
  3. AUTH.createUser(...) server-side — this bypasses disableSignUp. Catching USER_ALREADY_EXISTS and looking up the existing user makes the step an idempotent retry.
  4. AUTH.signInEmail(...) returns a session and its Set-Cookie headers.
  5. AUTH.acceptInvitation({ headers, invitationId }) writes the membership row.
  6. Mark the invitation accepted, forward the Set-Cookie to the browser, and return { redirectTo: "/dashboard" }.

Several “obvious” pieces are deliberately absent, and the reasons matter:

  • No queue for cache invalidation. Cloudflare Queues are work-distribution (one consumer per queue), not pub/sub. The design uses service-binding RPC fan-out plus KV cache versioning instead — see Tenant Resolution.
  • No shared secret between the admin and auth workers to gate Better Auth’s organization.create. That endpoint always rejects; the admin worker creates orgs via direct Drizzle inserts in AdminApiEntrypoint.
  • No HMAC-signed header for tenant context between workers. The typed RPC parameter (AuthEntrypoint.getSession(headers, tenant)) is the carrier.
  • No JWT for the admin panel. Cloudflare Access provides the durable session; every admin request re-validates the CF Access JWT plus the global_admins row.
  • No cross-subdomain cookies. crossSubDomainCookies stays false; isolation comes from host-only cookies plus explicit CSRF/origin enforcement.

Splitting apps/admin from apps/server follows Cloudflare Access’s grain — one Access application per hostname is the clean integration, and a separate worker means operator bugs can’t touch tenant traffic and operator deploys stay independent. The admin surface doesn’t belong in apps/auth either: that worker is binding-only with no public surface, whereas the admin worker is the public operator surface. And apps/app stays a pure static-assets worker rather than its own logic worker — the tenant API lives in apps/server because that’s where tenantMiddleware runs and where the authentication context is established; splitting it out would duplicate auth middleware and complicate the cookie-domain story.

The worker boundaries are one axis; the package boundaries are a separate one, covered in Deep Modules.