Skip to content

Auth & SSO

Identity is where multi-tenancy is easiest to get subtly wrong. Misconfigure one thing and a user in tenant A can act as a user in tenant B:

  • a cookie scoped to the wrong domain,
  • a forwarded header you trusted blindly,
  • a default account-linking rule that merges accounts across orgs.

The whole auth surface lives in a single worker, apps/auth, which runs the only Better Auth instance in the system and serves every tenant from it. That one-instance design is what makes the rules below tractable: there is exactly one place to get cookies, sessions, and JWT scoping right.

The auth worker builds a fresh Better Auth instance per request through a factory, createAuth(db, env, ctx, options). It receives the already-resolved tenant context plus any extra IdP origins a registration attempt needs.

The load-bearing detail: the tenant arrives as a typed parameter, not as a header the caller could spoof. Nothing in this config trusts an unsanitized forwarded header.

apps/auth/src/lib/auth.ts
betterAuth({
appName: brand.appName,
secret: env.BETTER_AUTH_SECRET,
baseURL: {
allowedHosts: authAllowedHosts, // wildcard first-party hosts + active custom hosts snapshot
protocol: "https",
},
basePath: "/api/auth",
// Better Auth automatically adds allowedHosts to trustedOrigins.
// Only add local-dev origins and short-lived validated IdP discovery origins here.
trustedOrigins: async () => [...devTrustedOrigins, ...extraTrustedOrigins],
emailAndPassword: {
enabled: true,
disableSignUp: true, // self-serve sign-up off; invitation-only
requireEmailVerification: true,
},
account: {
accountLinking: {
enabled: true,
allowDifferentEmails: false,
trustedProviders: [], // empty: provisionUser approves explicitly
},
},
advanced: {
useSecureCookies: true,
defaultCookieAttributes: { sameSite: "lax", httpOnly: true, secure: true },
cookies: {
session_token: { name: "session_token_v1", attributes: { httpOnly: true, sameSite: "lax", secure: true } },
},
},
databaseHooks: { /* see below */ },
plugins: [ /* see below */ ],
});

The object form with allowedHosts is what lets one instance serve every tenant. The key behavior is OIDC callback URL derivation: the address the identity provider sends the user back to after login. Better Auth builds it from the inbound request’s host at runtime rather than from a fixed config value. So a per-tenant absolute callback like https://acme.app.example.com/api/auth/sso/callback/{providerId} resolves correctly even though there is only one auth worker behind all of them.

That runtime host resolution is also the system’s sharpest edge. In Better Auth v1.6, the dynamic host is read in this order:

  1. x-forwarded-host
  2. host
  3. the request URL

That ordering makes the proxy boundary load-bearing. Before apps/server calls AUTH.fetch(...), AUTH.getSession(...), or AUTH.getToken(...), it must:

  • reconstruct the auth-bound request,
  • pin host to the already-validated public host, and
  • strip or overwrite x-forwarded-host and x-forwarded-proto.

Two more deliberate choices in this block:

  • No fallback. In production, an unknown host should fail closed, not silently resolve to some first-party origin. Omitting fallback makes unrecognized hosts an error.
  • allowedHosts is a live snapshot, not a timer. Custom-hostname support needs the set of active custom hosts to be current. The @repo/tenancy package owns that snapshot and its invalidation; a blind “refresh every five minutes” loop is too coarse for tenant suspension, hostname deletion, or a freshly added domain.

Every tenant user arrives through invitation acceptance, never through a public sign-up form — see operator-led onboarding. Self-serve sign-up would bypass the invitation gate entirely, which is incompatible with operator-led onboarding, so disableSignUp: true is set globally. Invitation acceptance still creates users: the /api/invitations/accept/:invitationId endpoint goes through the server-side auth.api.createUser admin path, which is allowed to bypass disableSignUp.

Pinning the active org in session.create.before

Section titled “Pinning the active org in session.create.before”

The session-creation hook is where the host’s tenant becomes the session’s active org. It reads the typed RPC tenant parameter — not a header — enforces membership, sets activeOrganizationId, and rejects any user who has no membership in this tenant.

apps/auth/src/lib/auth.ts
session: {
create: {
before: async (session, hookCtx) => {
if (!tenant) return { data: session }; // apex / no tenant
const [member] = await db.select().from(members)
.where(and(
eq(members.userId, session.userId),
eq(members.organizationId, tenant.organizationId),
)).limit(1);
if (!member) {
throw new APIError("FORBIDDEN", { message: "No membership in this tenant" });
}
return {
data: {
...session,
activeOrganizationId: tenant.organizationId,
activeOrgRole: member.role,
// ... existing platform / expiresAt logic ...
},
};
},
},
update: {
before: async (session) => {
const updateData = session as Record<string, unknown>;
if (updateData.activeOrganizationId !== undefined && tenant) {
if (updateData.activeOrganizationId !== tenant.organizationId) {
throw new APIError("FORBIDDEN", { message: "Cannot switch tenant via setActive" });
}
}
return { data: session };
},
},
},

The update.before half closes a related hole: setActive can normally move a session to a different organization, but here the host pins org context, so any attempt to switch to a foreign tenant via setActive is rejected.

Cookies are the actual isolation boundary between tenants, and the configuration above leans on a few precise facts:

  • No Domain attribute means cookies are host-only by default. The design relies on exactly this, which is why crossSubDomainCookies stays disabled.
  • SameSite is not the isolation mechanism. (SameSite is the cookie attribute that controls whether a cookie rides along on cross-site requests.) Sibling tenant subdomains under the same registrable domain — its eTLD+1, the registrable domain like acme.com — are same-site to the browser, so SameSite=Strict does not block tenant-to-tenant CSRF on its own.
  • Auth and session cookies stay SameSite=lax to preserve OAuth/OIDC round-trips and Better Auth’s state-cookie expectations. Tenant and admin mutations are protected instead by explicit Origin checks, Better Auth’s built-in CSRF protection on auth routes, and custom non-simple-request guards on the system’s own mutation routes.
  • Per-subdomain cookie isolation is automatic, and custom-hostname isolation is too — a custom domain is a different eTLD+1 entirely from the SaaS zone.

Per-tenant SSO is what lets each organization bring its own identity provider. It is delivered by @better-auth/sso, a separate npm package that supports OIDC, OAuth2, and SAML 2.0 in one plugin. OIDC ships on day one; SAML is deferred to v2 (the plugin supports it — only the onboarding UI is deferred).

One scope note worth internalizing: Better Auth’s hosted self-service SSO dashboard is an enterprise / Better Auth Infrastructure feature. This template instead builds its own org-admin control plane on top of the OSS plugin endpoints.

apps/auth/src/lib/auth.ts
sso({
organizationProvisioning: { disabled: true }, // membership managed via org plugin
provisionUser: async ({ user, userInfo, provider }) => {
if (!userInfo.email_verified) {
throw new APIError("FORBIDDEN", { message: "Email not verified by IdP" });
}
if (!provider.organizationId) {
throw new APIError("INTERNAL_SERVER_ERROR", { message: "SSO provider missing org" });
}
if (!provider.domainVerified) {
throw new APIError("FORBIDDEN", { message: "SSO domain not verified" });
}
// Account-takeover prevention: only link to an existing user if that user is
// already a member of this org.
const existing = await db.query.users.findFirst({ where: { email: { eq: userInfo.email } } });
if (existing && existing.id !== user.id) {
const [m] = await db.select().from(members)
.where(and(eq(members.userId, existing.id), eq(members.organizationId, provider.organizationId)))
.limit(1);
if (!m) {
throw new APIError("FORBIDDEN", {
message: "Email belongs to a user without membership in this org",
});
}
}
},
provisionUserOnEveryLogin: false,
domainVerification: { enabled: true },
})

The provisionUser triple-gate is the most security-critical code on this page; the attack it defends against is covered in detail in Account linking and cross-tenant takeover below.

ssoCallbackGuardPlugin — runs before token exchange

Section titled “ssoCallbackGuardPlugin — runs before token exchange”

Rejecting inside provisionUser is too late. By then the authorization code exchange has already happened — that’s the OIDC step where the auth worker trades the one-time code from the IdP for tokens, and the code can only be spent once. To bind a callback to its tenant before the code is consumed, a custom before hook guards the callback path.

apps/auth/src/plugins/sso-callback-guard.ts
export const ssoCallbackGuardPlugin = (db, tenant: TenantContext) => ({
id: "sso-callback-guard",
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (!ctx.path.startsWith("/sign-in/sso/callback/")) return;
if (!tenant) throw new APIError("FORBIDDEN", { message: "Tenant required" });
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" });
}
}),
},
});

ssoEnforcementPlugin — blocks password sign-in when SSO is enforced

Section titled “ssoEnforcementPlugin — blocks password sign-in when SSO is enforced”

When an org turns on SSO enforcement, password sign-in must be refused at the API, not just hidden in the UI. This plugin rejects /sign-in/email whenever the resolved org has enforceSSO set.

apps/auth/src/plugins/sso-enforcement.ts
export const ssoEnforcementPlugin = (db, tenant: TenantContext) => ({
id: "sso-enforcement",
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/sign-in/email") return;
if (!tenant) return;
const org = await db.query.organizations.findFirst({
where: { id: { eq: tenant.organizationId } },
columns: { enforceSSO: true },
});
if (org?.enforceSSO) {
throw new APIError("FORBIDDEN", { message: "This organization requires SSO" });
}
}),
},
});

Two API details that the 2026 hooks surface enforces:

  • The hooks API takes a single middleware function, not the older { matcher, handler } array shape.
  • createAuthMiddleware and APIError import from better-auth/api: import { createAuthMiddleware, APIError } from "better-auth/api".

With enforcement on, the sequence below is what a sign-in looks like. The two guards fire at different points: ssoCallbackGuardPlugin runs before the code exchange, and provisionUser plus session.create.before run after it.

SSO sign-in when enforceSSO = true
Rendering diagram…

When enforceSSO is false, the flow is the ordinary one:

  1. /api/tenancy/current returns { enforceSSO: false, ... }.
  2. The UI shows the password form.
  3. The client calls authClient.signIn.email({ email, password }).
  4. The enforcement plugin’s before hook is a no-op.
  5. The standard Better Auth email/password path runs — still finishing in session.create.before, which enforces membership and sets activeOrganizationId.

The enforcement plugin is defense in depth. Hiding the password form is a convenience; the API is the source of truth, and it refuses password sign-in regardless of what the UI renders.

Org admins holding the manage_sso_config permission register a provider with POST /api/sso-config/providers carrying { providerId, issuer, clientId, clientSecret, domain }.

The server handler overrides organizationId from c.var.tenant.organizationId and never trusts the body. It then:

  • normalizes and validates the issuer URL,
  • computes the discovery origins Better Auth will need to trust,
  • encrypts clientSecret at rest (see SSO client secrets), and
  • calls a dedicated auth-worker registration path.

This deliberately is not a naive proxy to /api/auth/sso/register. Better Auth performs OIDC discovery during registration and checks those discovery URLs against trustedOrigins — the allowlist of origins Better Auth will accept redirects from and make requests to. The docs also note that request is undefined during direct auth.api.* calls, so registration needs a request-scoped createAuth(..., { extraTrustedOrigins }) path — or an equivalent typed RPC — that can inject the validated IdP origins for just that one registration attempt.

Domain verification uses Better Auth’s built-in token flow: registration returns a verification token, the tenant publishes a _better-auth-token-{providerId} DNS record (or the configured prefix), the token expires after one week, and the UI can request a fresh one. Even so, provider.organizationId === tenant.organizationId plus the membership checks remain the real admission gate; domainVerified is a hardening signal, not the sole authorization primitive.

Enforcement is toggled separately with PATCH /api/sso-config/enforcement carrying { enabled: true }, and the system requires at least one verified SSO provider before enforcement can be turned on.

Downstream services authenticate with short-lived JWTs rather than session cookies, which raises a multi-tenant question the single-tenant template never had to answer: a token minted on tenant A must never be accepted by tenant B. The answer is to scope every claim to one host.

Better Auth’s jwt plugin issues 15-minute JWTs for downstream services. Here the issuer, audience, and an org claim are all pinned to the current tenant’s host.

apps/auth/src/lib/auth.ts
jwt({
jwt: {
issuer: tenant ? `https://${tenant.host}` : env.FALLBACK_APP_URL,
audience: tenant ? `https://${tenant.host}` : env.FALLBACK_APP_URL,
expirationTime: "15m",
definePayload: ({ user, session }) => ({
sub: user.id,
email: (user as { email: string }).email,
roleSlugs: (user as UserWithStatusFields).roleSlugs,
platform: session.platform,
org: tenant ? {
id: tenant.organizationId,
host: tenant.host,
sessionVersion: tenant.sessionVersion,
} : null,
}),
},
jwks: { rotationInterval: 30 * 24 * 60 * 60 },
})

Scoping the token at mint time only helps if every consumer checks the scope. A verifier of a tenant JWT must confirm all five of these — the claim shapes referenced are { aud, iss, org } and { id, host, sessionVersion }:

Note that invariant 5 is >=, not ===. Several version bumps may be in flight at once, and a fresh JWT minted just after a bump should not be rejected for trailing the database by a step.

Re-implementing these checks in every service is how they drift. Phase C consolidates them into @repo/auth-tokens.verifyTenantJwt(token, opts) so consumers call one function — see the auth-tokens package.

The existing per-platform session expiry is preserved: web sessions last one hour, mobile sessions seven days.

The revocation lever is a new organization.session_version column (integer, default 0), bumped on suspension and restore. A JWT verifier rejects any token whose claimed version is stale, which forces a re-authentication.

Tenant suspension revokes sessions immediately

Section titled “Tenant suspension revokes sessions immediately”

Suspending a tenant has to cut live sessions at once, not wait for cookie expiry — otherwise a tenant suspended for a ToS violation keeps one hour to seven days of access. In the same transaction that sets suspended_at, two statements run:

DELETE FROM session WHERE active_organization_id = orgId;
UPDATE organization SET session_version = session_version + 1 WHERE id = orgId;

This is implemented by the tenantOperations.suspend service (see tenant operations). It is the only correct call path, because the same service also handles the dual-scope audit and cache invalidation that suspension requires.

This is the attack the provisionUser gate above exists to stop, and it is worth understanding concretely. Better Auth’s default account linking merges users by email address. Without a membership gate, a malicious org admin could take over a victim’s account in other organizations:

The provisionUser hook blocks this by requiring three conditions before any link:

  • The IdP asserts email_verified: true.
  • The existing user is already a member of this org.
  • The SSO provider’s domain is verified.

That triple-gate is reinforced by account.accountLinking.trustedProviders: [] being empty, so default linking only ever proceeds when provisionUser explicitly passes.

A few sharp edges in this design that are easy to trip over later:

  • OIDC vs SAML callback handling. With the object-form baseURL, the OIDC callback is derived from the inbound request. SAML, by contrast, stores absolute callbacks per provider — which is precisely why SAML is deferred to v2.
  • provisionUser runs after the token exchange. Any check that must happen before the IdP code is consumed belongs in ssoCallbackGuardPlugin, not provisionUser.
  • Keep trustedOrigins small. Since allowedHosts is added to trustedOrigins automatically, the extra trustedOrigins list should stay purpose-specific (local dev and validated IdP discovery) rather than echoing the inbound host.
  • Per-request, not a module-level singleton. The instance is built per request via createAuth(db, env, ctx, options). A module-level singleton would force AsyncLocalStorage and complicate testing for no measurable performance gain.
  • MFA placement. For SSO logins, MFA can only be enforced at the IdP layer — Better Auth does not layer MFA on top of SSO. For email/password sign-ins, Better Auth’s existing twoFactor plugin handles it.