Skip to content

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.

The system has two completely separate populations of identity, and they must never mix:

  • Tenant users — customers. They live in Better Auth’s users table and are managed by tenant admins.
  • Operators — SaaS staff. They live in the global_admins table 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.

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.

InvariantMechanism
Operators are never in the users tableglobal_admins is a separate table; gates check it specifically
Tenant cookies never cross hostsDomain attribute unset (host-only); crossSubDomainCookies: false
Cross-tenant CSRF is blockedHost-only cookies + explicit Origin/CSRF enforcement on every state-changing route
A JWT minted for tenant A is never accepted by tenant BPer-host aud + iss; org.host, org.id, org.sessionVersion claims
An operator session never shares state with a tenant sessionThe admin worker has no cookie session; the CF Access JWT is verified per request
An operator action is always visible to the affected tenantauditLogService.createDualScope writes to both views
audit_logs is append-onlyA Postgres trigger raises on UPDATE/DELETE
Self-serve sign-up is disableddisableSignUp: true globally; users arrive only via invitation
Better Auth’s organization.create always rejectsAn unconditional before hook
Tenants are creatable only by operatorsAdminApiEntrypoint.createTenantOnBehalfOf is the only public path

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 Domain attribute, 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=strict does not, on its own, stop tenant-to-tenant CSRF.
  • The main auth/session cookies stay SameSite=lax so OAuth/OIDC callback and state-cookie flows keep working. Strict can still be useful for selected non-OAuth cookies, but it is not the tenant-isolation boundary.
  • crossSubDomainCookies stays 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.

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:

  1. aud === "https://${expectedHost}" (tenant-host scoping)
  2. iss === "https://${expectedHost}"
  3. org.host === expectedHost
  4. org.id === expectedOrgId
  5. org.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" });
}
}),
}
closed

The provisionUser hook requires three conditions before any link proceeds:

  1. userInfo.email_verified === true — the IdP attests the email.
  2. The existing user is already a member of this org — no cross-org linking.
  3. 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.

closed

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:

  1. A super-admin POSTs /api/admin/global-admins { email, name, role }. The server returns the enrollment token (shown once).
  2. The super-admin shares the token with the new operator out-of-band (Slack, a password manager).
  3. The operator authenticates via Cloudflare Access and presents an x-admin-enrollment-token header on their first request.
  4. 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.

closed

The fix is a two-step flow:

  1. The POST creates a tenant_custom_hostnames row with a verification_token and makes no Cloudflare API call yet. The tenant must add a TXT record (_app-example-verify.<host> = token).
  2. 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.

closed

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_slugs table with reason: "deleted_org" and is never reissued.

See Tenant Resolution for how slugs are resolved and tombstoned.

closed

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.

mitigated

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.

closed

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.

closed

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”
closed

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.

closed

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.

closed

Several controls stack here:

  • The OPERATOR_PERMISSIONS matrix gates platform.manage_global_admins to super_admin only.
  • Only a super_admin can grant super_admin to another operator (an extra check in the handler).
  • support cannot read admin.* audit events; the security role has read-only access.
  • The audit_logs Postgres 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_admin count drop to zero.
mitigated

Cross-tenant reads are allowed but tightly controlled:

  • admin.support.query is 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.
closed

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.

mitigated

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”
accepted

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.

accepted

For v1, MFA enforcement leans on two layers:

  1. A CF Access policy of Require: Authentication Method ∈ {mfa, swk, hwk, otp} — which can be bypassed if the policy is misconfigured.
  2. 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.

closed

Logout differs by identity domain:

  • Tenant: Better Auth’s signOut endpoint 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.
accepted

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.

closed

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" and ALLOW_DEV_TENANT_HEADER === "true" (a Cloudflare Secret). A CI guard rejects deploys to staging or prod where the merged config has NODE_ENV !== "production".
  • The admin email fallback (LOCAL_DEV_ADMIN_EMAIL) uses the same pattern: NODE_ENV === "development" and ALLOW_DEV_ADMIN_AUTH === "true". A misconfigured production deploy fails hard rather than silently authenticating.
closed

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.

closed

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.

The capstone view: every named attack, its mitigation, and where the relevant mechanism is documented.

ThreatMitigationWhere
Cross-tenant JWT forgeryPer-tenant aud/iss + org claim + sessionVersionAuth & SSO
SSO email-link account takeoverprovisionUser membership + email_verified + domainVerifiedAuth & SSO
SSO callback to wrong tenantssoCallbackGuardPlugin before token exchangeAuth & SSO
Operator account takeover via IdP raceEnrollment-token first-login (no email fallback)Admin Panel & Operators
Cross-tenant CSRFHost-only cookies + explicit origin/CSRF checksAuth & SSO
workers.dev bypassworkers_dev: false + Host-header guardArchitecture & Topology
trustedOrigins echoExplicit allowedHosts; minimal extra trusted originsAuth & SSO
Forwarded host confusionReconstruct request/headers before crossing the server → auth boundaryAuth & SSO
OIDC client secret leakpgcrypto + Postgres view + log redactionDeep Modules
CF API token blast radiusSingle zone, 3 permissions, secret store, quarterly rotationCustom Hostnames
/api/tenancy/current enumerationAggressive rate-limit, minimal infoThis page
Cross-tenant data bleed via shared user tableprovisionUser membership gate; v2 considers tenant-scoped identityAuth & SSO
Slug squatting / PunycodeDenylist + NFC + xn-- reject + format regexTenant Resolution
Subdomain takeover after deletionTombstone in reserved_slugsTenant Resolution
Apex tenant === null route abuseDefault-deny: only login picker + /api/tenancy/current allowedTenant Resolution
Tenant suspension lagsession_version bump + session DELETE in the same txAuth & SSO
Operator privilege escalationMatrix gate + audit_logs immutabilityAdmin Panel & Operators
Operator data exfiltrationCRITICAL audit + row cap + rate limitAdmin Panel & Operators
Custom hostname squattingTwo-step TXT pre-verificationCustom Hostnames
Audit log tamperingPostgres trigger blocks UPDATE/DELETESchema & Migrations
MFA bypass via misconfigured Access policyUn-mitigatable in v1 (no amr claim); IdP-side MFA + policy reviewAdmin Panel & Operators