Security
Multi-tenancy is mostly a security problem wearing an architecture costume. There are two identity domains — tenant users and operators — and nearly every threat on this page reduces to one of two questions: how could tenant A touch tenant B’s data, or how could an ordinary user become an operator? Every decision in the earlier chapters — host-only cookies, per-tenant JWT claims, the separate admin worker — exists to make both answers “they can’t.”
This chapter lays out the threat model explicitly: who the attackers are, what isolation invariants must always hold, and a concrete mitigation for each named attack. Each section states the attack first — how the attacker actually tries it — then the defense.
Throughout, each threat carries a status: closed means the design fully addresses it, mitigated means the risk is reduced but not eliminated, and accepted means the residual risk is understood and tolerated for v1.
Two identity domains
Section titled “Two identity domains”The system has two completely separate populations of identity, and they must never mix:
- Tenant users — customers. They live in Better Auth’s
userstable and are managed by tenant admins. - Operators — SaaS staff. They live in the
global_adminstable and are gated by Cloudflare Access, never by a tenant session.
The attackers the design defends against follow from those two domains:
- A tenant admin trying to escalate to global admin, or to read another tenant’s data.
- An unauthenticated attacker registering sensitive slugs or custom hostnames they don’t own.
- A malicious tenant pointing an attacker-controlled OIDC provider at the system to harvest cross-tenant identities.
- A compromised IdP at the Cloudflare Access layer issuing tokens for unauthorized operators.
- A former operator with a still-valid Cloudflare Access session.
- An authenticated tenant user calling a global-admin endpoint.
- A compromised Postgres backup leaking SSO client secrets.
Identity isolation invariants
Section titled “Identity isolation invariants”These are the load-bearing guarantees. Every one is enforced by a concrete mechanism, not by convention, and a reviewer touching the multi-tenancy surface should check that none of them have been weakened.
| Invariant | Mechanism |
|---|---|
Operators are never in the users table | global_admins is a separate table; gates check it specifically |
| Tenant cookies never cross hosts | Domain attribute unset (host-only); crossSubDomainCookies: false |
| Cross-tenant CSRF is blocked | Host-only cookies + explicit Origin/CSRF enforcement on every state-changing route |
| A JWT minted for tenant A is never accepted by tenant B | Per-host aud + iss; org.host, org.id, org.sessionVersion claims |
| An operator session never shares state with a tenant session | The admin worker has no cookie session; the CF Access JWT is verified per request |
| An operator action is always visible to the affected tenant | auditLogService.createDualScope writes to both views |
audit_logs is append-only | A Postgres trigger raises on UPDATE/DELETE |
| Self-serve sign-up is disabled | disableSignUp: true globally; users arrive only via invitation |
Better Auth’s organization.create always rejects | An unconditional before hook |
| Tenants are creatable only by operators | AdminApiEntrypoint.createTenantOnBehalfOf is the only public path |
Cookie isolation
Section titled “Cookie isolation”The attack: a user logged into acme.app.example.com visits a malicious tenant at
evil.app.example.com; a script there fires a request that rides Acme’s session cookie
into Acme’s API. Isolation between tenant subdomains therefore starts at the cookie. The
whole model is per-host cookies plus explicit CSRF/origin enforcement, and it’s worth
understanding why the obvious shortcut — leaning on SameSite — doesn’t carry the
weight.
- The session cookie carries no
Domainattribute, so the browser scopes it to the exact host that set it. - Sibling tenant subdomains share a registrable domain, so the browser treats them as
same-site. That means
SameSite=strictdoes not, on its own, stop tenant-to-tenant CSRF. - The main auth/session cookies stay
SameSite=laxso OAuth/OIDC callback and state-cookie flows keep working.Strictcan still be useful for selected non-OAuth cookies, but it is not the tenant-isolation boundary. crossSubDomainCookiesstays off.- Better Auth’s
secondaryStorage(KV) caches session state by token, not by domain, so there’s no cross-host leakage through the cache either.
JWT scoping
Section titled “JWT scoping”The attack: an attacker takes a valid JWT minted for their own tenant and replays it against another tenant’s host, hoping a consumer will honor it and serve back someone else’s data. This cross-tenant token forgery is the central attack in a shared-database SaaS, so every JWT is scoped to exactly one host and one org, and every consumer verifies all five scoping properties.
On the mint side, Better Auth’s jwt plugin issues per-tenant claims:
{ iss: "https://acme.app.example.com", aud: "https://acme.app.example.com", sub: user.id, email, roleSlugs, platform, org: { id: orgId, host: "acme.app.example.com", sessionVersion: 5 }, exp: now + 15min,}On the verifier side, every consumer checks all five of:
aud === "https://${expectedHost}"(tenant-host scoping)iss === "https://${expectedHost}"org.host === expectedHostorg.id === expectedOrgIdorg.sessionVersion >= db.organization.session_version
After the deep-module consolidation, all five checks live behind
verifyTenantJwt(token, opts) so consumers never re-derive them — see
Deep Modules.
SSO callback validation before token exchange
Section titled “SSO callback validation before token exchange”The attack: a tenant self-registers its own OIDC provider, then replays its IdP’s
callback response against another tenant’s callback URL — a confused-deputy trick that
tries to get Better Auth to mint a session for the wrong tenant. The
ssoCallbackGuardPlugin runs on /sign-in/sso/callback/* before Better Auth
exchanges the authorization code:
hooks: { before: createAuthMiddleware(async (ctx) => { if (!ctx.path.startsWith("/sign-in/sso/callback/")) return; const providerId = ctx.path.split("/").pop(); const provider = await db.query.ssoProviders.findFirst({ where: { providerId: { eq: providerId } }, columns: { organizationId: true }, }); if (!provider || provider.organizationId !== tenant.organizationId) { throw new APIError("FORBIDDEN", { message: "Provider does not belong to this tenant" }); } }),}SSO email-link account takeover
Section titled “SSO email-link account takeover”The provisionUser hook requires three conditions before any link proceeds:
userInfo.email_verified === true— the IdP attests the email.- The existing user is already a member of this org — no cross-org linking.
- The SSO provider’s
domainVerified === true— the IdP’s ownership of the email domain was verified via a DNS TXT record.
account.accountLinking.trustedProviders: [] is left empty, so default linking only
proceeds when provisionUser passes.
Operator account takeover via IdP race
Section titled “Operator account takeover via IdP race”The mitigation is to have no email fallback. globalAdminMiddleware only matches
by cf_access_sub, and first-login binding requires a one-time enrollment_token
(24h TTL) issued out-of-band by a super_admin:
- A super-admin POSTs
/api/admin/global-admins { email, name, role }. The server returns the enrollment token (shown once). - The super-admin shares the token with the new operator out-of-band (Slack, a password manager).
- The operator authenticates via Cloudflare Access and presents an
x-admin-enrollment-tokenheader on their first request. - The middleware atomically claims the row with
UPDATE ... WHERE cf_access_sub IS NULL(race-safe).
An email cross-check at redemption catches the case where the IdP returned a different email than expected.
Custom hostname squatting
Section titled “Custom hostname squatting”The fix is a two-step flow:
- The POST creates a
tenant_custom_hostnamesrow with averification_tokenand makes no Cloudflare API call yet. The tenant must add a TXT record (_app-example-verify.<host>= token). - A verify endpoint resolves the TXT via DNS-over-HTTPS; only after success does the system call Cloudflare.
Per-org rate limits back this up: at most 10 pending hostnames at a time and 50 per day, plus a hard refusal on hostnames whose apex resolves to an IP the system doesn’t control unless the TXT record is present.
Slug squatting and tombstones
Section titled “Slug squatting and tombstones”The mitigations harden slug allocation at both ends:
- A reserved-slug denylist (around 50 entries) covers system names, common admin paths, marketing surfaces, DNS reserved labels, and common subdomains.
- A DNS-label-safe slug regex:
^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$. - The
xn--Punycode prefix is rejected — no IDN tenants in v1. - Slugs are lowercased and NFC-normalized before validation.
- On org soft-delete, the slug is moved to the
reserved_slugstable withreason: "deleted_org"and is never reissued.
See Tenant Resolution for how slugs are resolved and tombstoned.
SSO client secret encryption
Section titled “SSO client secret encryption”Secrets are protected with pgcrypto column-level encryption. The decryption key
lives in Cloudflare Secrets Store — not in the database and not in environment
variables. Better Auth’s SSO plugin reads from a sso_providers_decrypted view that
decrypts on read, with SET LOCAL app.sso_key = '...' per session. Application code
reads sso_providers (raw, encrypted) by default and uses withDecryptedSecret(...)
scoped closures when it needs plaintext.
As defense in depth, the logger middleware redacts any field named secret,
clientSecret, or enrollmentToken from structured log output.
CF API token blast radius
Section titled “CF API token blast radius”The attack: if the worker leaks CLOUDFLARE_API_TOKEN (log spill, compromised
build), an attacker holding it can do whatever it’s authorized for — so the question is
how much damage an account-wide token would allow versus a tightly scoped one.
CLOUDFLARE_API_TOKEN is scoped to the SaaS zone only, with three permissions —
Zone:Read, SSL and Certificates:Edit, and Custom Hostnames:Edit — never
account-wide. It’s stored as a Cloudflare Secret with quarterly rotation in the
runbook. A v2 hardening is to split read and write tokens, so the reconciler uses a
read token and admin actions use a write token.
workers.dev bypass
Section titled “workers.dev bypass”Every worker (apps/admin, apps/auth, apps/server, apps/app) sets
"workers_dev": false and "preview_urls": false. As defense in depth — and to catch
misrouted traffic — each worker’s first middleware also rejects any request whose
Host header isn’t its configured public hostname.
trustedOrigins echo abuse
Section titled “trustedOrigins echo abuse”Better Auth’s allowedHosts list is explicit and is automatically added to
trustedOrigins. The extra trustedOrigins callback is reserved only for local-dev
origins and the short-lived, validated IdP discovery origins needed during SSO
provider registration.
Forwarded-header confusion at the auth boundary
Section titled “Forwarded-header confusion at the auth boundary”Every call from apps/server to apps/auth reconstructs the request and headers, sets
host to the already-validated public host, and strips or overwrites
x-forwarded-host and x-forwarded-proto. Tenant resolution itself still trusts only
the Cloudflare-delivered Host header.
Tenant suspension revokes sessions
Section titled “Tenant suspension revokes sessions”tenantOperations.suspend(...) closes the gap in a single transaction:
UPDATE organization SET suspended_at = now(), session_version = session_version + 1 WHERE id = $1;
DELETE FROM session WHERE active_organization_id = $1;JWT verifiers then reject any claim where
org.sessionVersion < db.organization.session_version, so existing tokens stop working
immediately.
Operator privilege escalation
Section titled “Operator privilege escalation”Several controls stack here:
- The
OPERATOR_PERMISSIONSmatrix gatesplatform.manage_global_adminstosuper_adminonly. - Only a
super_admincan grantsuper_adminto another operator (an extra check in the handler). supportcannot readadmin.*audit events; thesecurityrole has read-only access.- The
audit_logsPostgres trigger blocks UPDATE/DELETE, so even a DB-credentialed engineer can’t quietly rewrite history. - An operator cannot deactivate themselves, and the system never lets the active
super_admincount drop to zero.
Cross-tenant operator queries
Section titled “Cross-tenant operator queries”Cross-tenant reads are allowed but tightly controlled:
admin.support.queryis classified CRITICAL — its audit write is transactional, not bufferable.- A hard row cap of 100 per query; pagination beyond that requires separate, separately audited calls.
- A per-operator rate limit via a RateLimiter Durable Object partition
(globalAdminId, "support_query"), 60 per hour. - The audit row records the SQL predicate digest plus the row count.
CSRF on tenancy and admin endpoints
Section titled “CSRF on tenancy and admin endpoints”The attack: the new tenancy and admin mutation routes are not Better Auth’s own endpoints, so they don’t automatically inherit its CSRF protection. An attacker who can get a logged-in admin to load a hostile page could fire a state-changing request that rides the admin’s session.
All Better Auth endpoints have built-in CSRF protection (an origin check plus a custom
header). New admin and tenancy mutation endpoints add the same Origin header check —
the value must equal the configured public hostname. Host-only cookies plus explicit
origin/CSRF enforcement are the primary defense here; SameSite is additional
hardening only.
Open redirect
Section titled “Open redirect”The attack: an attacker crafts a sign-in link with callbackURL=https://evil.com,
so a successful login bounces the victim to an attacker-controlled site — useful for
phishing or token theft.
signIn.sso({ callbackURL }) and similar calls rely on Better Auth’s existing
trustedOrigins check, which restricts callbackURL to the dynamic per-host set.
Combined with the host validation in trustedOrigins described above, an attacker
cannot redirect to https://evil.com.
Information disclosure on /api/tenancy/current
Section titled “Information disclosure on /api/tenancy/current”The attack: because this endpoint is unauthenticated (the login page needs it), an attacker can hammer it across hosts to enumerate which tenants exist and what SSO they have configured. It returns:
{ organizationId, slug, enforceSSO, providers: [{ providerId, label }], branding }It’s rate-limited aggressively per source IP (for example 60/min/IP across all hosts)
and returns minimal information: providerId and label only, with no internal IDs
and no IdP issuer URL leaked. This is treated as acceptable disclosure for v1.
MFA gap
Section titled “MFA gap”For v1, MFA enforcement leans on two layers:
- A CF Access policy of
Require: Authentication Method ∈ {mfa, swk, hwk, otp}— which can be bypassed if the policy is misconfigured. - IdP-side MFA enforcement (Google Workspace 2SV, Okta MFA policy).
The v2 plan is to call the CF Access Identity API server-side, verify the AMR value,
and cache it by identity_nonce.
Logout
Section titled “Logout”Logout differs by identity domain:
- Tenant: Better Auth’s
signOutendpoint clears the session cookie and revokes the session row. - Operator:
https://admin.example.com/cdn-cgi/access/logout, which is Cloudflare-managed. Per-app revocation propagates within about 30 seconds.
CF Access logout CSRF
Section titled “CF Access logout CSRF”A malicious tenant could embed <img src="https://admin.example.com/cdn-cgi/access/logout">
on a tenant page. An operator browsing the page during support would get logged out — a
nuisance, not an exfiltration. The endpoint is Cloudflare-controlled, so CSRF tokens
can’t be added to it. The operator runbook recommends a separate browser profile for
admin access.
Local-dev surface hardening
Section titled “Local-dev surface hardening”Development conveniences are double-gated so they can never leak into production:
- The dev tenant header (
X-Dev-Tenant-Slug) is behind a two-factor gate:NODE_ENV === "development"andALLOW_DEV_TENANT_HEADER === "true"(a Cloudflare Secret). A CI guard rejects deploys to staging or prod where the merged config hasNODE_ENV !== "production". - The admin email fallback (
LOCAL_DEV_ADMIN_EMAIL) uses the same pattern:NODE_ENV === "development"andALLOW_DEV_ADMIN_AUTH === "true". A misconfigured production deploy fails hard rather than silently authenticating.
Audit log immutability
Section titled “Audit log immutability”The attack: an operator (or a DB-credentialed engineer) tampers with audit_logs
to erase evidence of an action they shouldn’t have taken — so the control can’t live in
application code alone, since the attacker may have a SQL prompt.
A Postgres trigger blocks UPDATE/DELETE on audit_logs, and application code never
issues UPDATE/DELETE against the table — the trigger is the invariant, not a
convention. The DB role the workers use holds only INSERT, SELECT on audit_logs; the
migration role separately holds the DDL permissions needed to install the trigger.
Reserved-slug enforcement at the DB level
Section titled “Reserved-slug enforcement at the DB level”The attack: two requests race to claim the same slug at the same instant, both pass the application’s “is it free?” check, and both try to insert — a TOCTOU window the app-level check alone can’t close.
A UNIQUE constraint on organization.slug and a UNIQUE constraint on
reserved_slugs.slug make slug collisions impossible at the database level. The
application also checks both before insert; the UNIQUE constraint catches the second
writer in a race.
Threat ↔ mitigation matrix
Section titled “Threat ↔ mitigation matrix”The capstone view: every named attack, its mitigation, and where the relevant mechanism is documented.
| Threat | Mitigation | Where |
|---|---|---|
| Cross-tenant JWT forgery | Per-tenant aud/iss + org claim + sessionVersion | Auth & SSO |
| SSO email-link account takeover | provisionUser membership + email_verified + domainVerified | Auth & SSO |
| SSO callback to wrong tenant | ssoCallbackGuardPlugin before token exchange | Auth & SSO |
| Operator account takeover via IdP race | Enrollment-token first-login (no email fallback) | Admin Panel & Operators |
| Cross-tenant CSRF | Host-only cookies + explicit origin/CSRF checks | Auth & SSO |
workers.dev bypass | workers_dev: false + Host-header guard | Architecture & Topology |
trustedOrigins echo | Explicit allowedHosts; minimal extra trusted origins | Auth & SSO |
| Forwarded host confusion | Reconstruct request/headers before crossing the server → auth boundary | Auth & SSO |
| OIDC client secret leak | pgcrypto + Postgres view + log redaction | Deep Modules |
| CF API token blast radius | Single zone, 3 permissions, secret store, quarterly rotation | Custom Hostnames |
/api/tenancy/current enumeration | Aggressive rate-limit, minimal info | This page |
| Cross-tenant data bleed via shared user table | provisionUser membership gate; v2 considers tenant-scoped identity | Auth & SSO |
| Slug squatting / Punycode | Denylist + NFC + xn-- reject + format regex | Tenant Resolution |
| Subdomain takeover after deletion | Tombstone in reserved_slugs | Tenant Resolution |
Apex tenant === null route abuse | Default-deny: only login picker + /api/tenancy/current allowed | Tenant Resolution |
| Tenant suspension lag | session_version bump + session DELETE in the same tx | Auth & SSO |
| Operator privilege escalation | Matrix gate + audit_logs immutability | Admin Panel & Operators |
| Operator data exfiltration | CRITICAL audit + row cap + rate limit | Admin Panel & Operators |
| Custom hostname squatting | Two-step TXT pre-verification | Custom Hostnames |
| Audit log tampering | Postgres trigger blocks UPDATE/DELETE | Schema & Migrations |
| MFA bypass via misconfigured Access policy | Un-mitigatable in v1 (no amr claim); IdP-side MFA + policy review | Admin Panel & Operators |