Multi-Tenant Authentication Patterns

Multi-tenancy (serving multiple isolated customers from a single application deployment) requires answering three questions on every request:

  1. Who is this user? (authentication — handled by the auth provider)
  2. Which tenant are they acting as? (tenant selection — needed because one user can belong to multiple tenants)
  3. Are they allowed to do this? (authorization — policy evaluation against the selected tenant)

This note covers how tenant selection and management work in practice, from MVP to commercial-grade.

Prerequisites

The Tenant Selection Problem

A user can belong to multiple organizations (think Slack workspaces, GitHub orgs, Notion teams). The server needs to know which tenant context to use for the current request. There are three components:

ComponentTrust levelSourcePurpose
JWT sub claimTrusted (signed)Auth providerIdentifies the user
JWT tenants mapTrusted (signed)Auth providerProves which tenants the user belongs to
X-Tenant-ID headerUntrusted (user-set)FrontendSelects which tenant to act as now

The security model:

  • The frontend sets X-Tenant-ID based on which workspace the user has selected in the UI
  • The backend verifies this untrusted header against the trusted JWT tenants map
  • If the tenant ID is in the map, the user is authorized to act as that tenant
  • If not, reject with 403

Without this verification, any user could impersonate any tenant by forging the header.

Tenant Source of Truth: Three Patterns

Pattern 1: Auth-provider managed (MVP / LearnOS)

The auth provider (Descope, Auth0 Organizations) owns the tenant-to-user mapping:

User signs up → Descope creates user
Admin adds user to org → Descope updates user's tenant list
User logs in → JWT includes tenants map from Descope's DB
Backend verifies X-Tenant-ID against JWT → zero DB queries

When it works: Early-stage products where the auth provider handles all org management. Simple, fast (no DB lookup for authz), and you don’t need to build invitation flows.

When it breaks: User removed from org but their JWT hasn’t expired yet (stale for up to 1 hour). Can’t store tenant metadata (plan, settings, billing) in the auth provider. Vendor lock-in on a core business concept.

Pattern 2: App-managed (full control)

Your application database is the source of truth for tenant membership:

User signs up → Your app creates user + default tenant + membership
User creates org → INSERT INTO tenants + INSERT INTO memberships (role=owner)
Admin invites member → INSERT INTO invitations → accept → INSERT INTO memberships
Request arrives → JWT provides sub only → SELECT membership from YOUR DB

The JWT carries only the user identity (sub, email). Tenant membership is looked up per-request:

// Verify tenant access via DB (not JWT)
let is_member = membership_repo
    .exists(user_id, tenant_id)
    .await?;
if !is_member {
    return Err(TenantError::NotAMember);
}

When it works: Commercial products that need real-time membership changes, audit trails, custom roles, approval workflows, or SCIM (System for Cross-domain Identity Management — an open standard for provisioning/deprovisioning users from enterprise IdPs like Okta).

When it breaks: Every request hits the DB for membership verification (mitigated with short-TTL cache).

Pattern 3: Hybrid (production-grade)

Combine both: JWT for the fast path, DB as source of truth for critical operations.

Fast path (reads, normal operations):
  → Verify X-Tenant-ID against JWT tenants map (zero DB queries)

Critical path (admin actions, billing, writes that affect other users):
  → Also check DB memberships table (real-time accuracy)

Background:
  → When membership changes, revoke affected user sessions via auth provider API
  → Next login generates JWT with updated tenants map

This gives sub-millisecond verification for 99% of requests while maintaining real-time accuracy where it matters.

Tenant Lifecycle in a Commercial Product

Tables

CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    slug TEXT UNIQUE NOT NULL,  -- for URLs: app.example.com/acme
    plan TEXT NOT NULL DEFAULT 'free',
    stripe_customer_id TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE memberships (
    user_id UUID REFERENCES users(id),
    tenant_id UUID REFERENCES tenants(id),
    role TEXT NOT NULL DEFAULT 'member',  -- owner, admin, member, viewer
    joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, tenant_id)
);
 
CREATE TABLE invitations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID REFERENCES tenants(id),
    email TEXT NOT NULL,
    role TEXT NOT NULL DEFAULT 'member',
    token TEXT UNIQUE NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    accepted_at TIMESTAMPTZ,
    created_by UUID REFERENCES users(id)
);

Flows

Signup: User registers via auth provider → app receives webhook or first JWT → creates default personal tenant + membership (role=owner). Every user always has at least one tenant.

Org creation: Authenticated user calls POST /api/orgs { name: "Acme" } → creates tenant row + membership row (role=owner). Optionally syncs to auth provider so JWT includes the new tenant on next refresh.

Invitation: Org admin calls POST /api/orgs/:id/invite { email, role } → creates invitation with crypto-random token and TTL → sends email. Recipient clicks link → POST /api/invitations/:token/accept → creates membership.

Removal: Admin calls DELETE /api/orgs/:id/members/:user_id → deletes membership row → revokes user’s sessions via auth provider API → user’s next JWT won’t include this tenant.

Switching tenants: Frontend calls GET /api/me/memberships → displays org switcher UI → user picks one → frontend stores selection and sends X-Tenant-ID on subsequent requests.

Billing attachment

Billing is per-tenant, not per-user. The Stripe Customer object maps to a tenant:

tenants.stripe_customer_id → Stripe Customer
Stripe Subscription → attached to Customer (= tenant)
When tenant upgrades plan → all members get the features

This is why the plan column lives on the tenants table. Individual users don’t have plans — their access is determined by which tenant they’re acting as and what plan that tenant is on.

Syncing Tenant Creation to the Auth Provider

When using Pattern 3 (hybrid), creating a tenant in your DB is not enough — the auth provider must also know about it so the next JWT includes the new tenant in its tenants map. This requires calling the provider’s management API:

// After creating tenant in your DB:
async fn create_org(&self, user_id: Uuid, name: &str) -> Result<Tenant> {
    // 1. Create in YOUR database (source of truth)
    let tenant = self.tenant_repo.create(name).await?;
    self.membership_repo.add(user_id, tenant.id, Role::Owner).await?;
 
    // 2. Sync to auth provider (so JWT includes it on next refresh)
    self.auth_provider.create_tenant(&tenant.id.to_string(), name).await?;
    self.auth_provider.associate_user(&user_id_external, &tenant.id.to_string()).await?;
 
    // 3. Optionally trigger token refresh on the frontend
    Ok(tenant)
}

Provider-specific APIs:

ProviderCreate tenantAssociate user
DescopePOST /v1/mgmt/tenant/createPOST /v1/mgmt/user/update/tenant
Auth0 OrganizationsPOST /api/v2/organizationsPOST /api/v2/organizations/:id/members
KeycloakPOST /admin/realms/:realm/groupsPUT /admin/realms/:realm/users/:id/groups/:groupId

After the sync, the user needs a fresh JWT to see the new tenant. Options:

  • Refresh token exchange — frontend calls token endpoint with refresh_token grant. New access token includes updated tenants map. Best UX (invisible to user).
  • Silent re-auth — redirect to provider’s authorize endpoint with prompt=none. Works with session cookies. Slightly slower.
  • Forced re-login — worst UX, but simplest. Acceptable for rare operations like org creation.

The sync gap

Between creating the tenant in your DB and the user obtaining a fresh JWT, there’s a brief window where X-Tenant-ID verification against the JWT will fail for the new tenant. Handle this gracefully: the create-org endpoint can return the tenant data directly (no JWT needed for the response), and the frontend can trigger a token refresh before attempting to switch to the new tenant.

JIT User Provisioning

JIT (Just-In-Time) provisioning creates a database row for users on their first authenticated request, rather than requiring a separate signup step:

// In the auth middleware, after JWT verification:
let user_id = user_repo
    .upsert_from_login(&claims.sub, email, display_name)
    .await?;

upsert_from_login executes:

INSERT INTO users (external_id, email, display_name, tenant_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (external_id, tenant_id)
DO UPDATE SET email = $2, display_name = $3, last_login_at = now()
RETURNING id;

This eliminates the “user doesn’t exist yet” edge case. The auth provider handles signup; your DB handles local user records. First login = INSERT, repeat login = UPDATE (email/display name might change). Downstream queries never hit a missing FK.

Identity vs User: The Global-Local Split

In a multi-tenant system, there’s a fundamental tension: a human is one person globally, but may have different roles, permissions, and data in each tenant. The clean solution is a two-layer model:

identities (global — one per human, keyed by auth provider's `sub`)
    id, external_id, email, display_name

memberships (cross-tenant — links identity to tenant with a role)
    identity_id, tenant_id, role, joined_at

users (per-tenant — local projection, holds FK relationships)
    id, tenant_id, identity_id

Why identities + memberships instead of just users with a tenant_id?

If users is the only table, you end up with:

  • Duplicate email/display_name across tenant-scoped rows (one “alice@example.com” row per tenant)
  • No single place to answer “which tenants does this person belong to?” without scanning all tenant-scoped user rows
  • Updates to email/display_name must propagate to N rows

The identity + membership layer gives you:

  • One row per human — canonical email/display_name lives here
  • Memberships as explicit relationships — queryable, auditable, revocable

Why does users still exist alongside memberships?

At first glance, memberships already links identity to tenant — so why keep users at all? The answer is foreign key ergonomics. Every existing per-tenant domain table (areas, courses, chunks, learning_progress, etc.) needs to reference “the person who created/owns this thing within this tenant.” Those FKs need to point somewhere:

  • Without users: those tables would need REFERENCES memberships(identity_id, tenant_id) — a composite FK. Every query that touches “who owns this area” now joins on two columns. Awkward and pollutes every domain table with authorization concerns.
  • With users: those tables do REFERENCES users(id) — a single UUID FK. Clean, simple, and the users row is automatically created when an identity first accesses a tenant (via ON CONFLICT upsert).

Think of users as a local projection — a per-tenant materialized pointer that gives domain tables a simple FK target. It holds no business data of its own (no email, no name — those live on identities). Its only columns are id, tenant_id, identity_id.

Concrete example — the areas table:

Without users, domain tables must reference memberships directly:

-- WITHOUT users: every domain table carries a composite FK
CREATE TABLE areas (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    created_by_identity UUID NOT NULL,
    title TEXT NOT NULL,
    FOREIGN KEY (created_by_identity, tenant_id)
        REFERENCES memberships(identity_id, tenant_id)
);
 
-- Query: "who created this area?"
SELECT i.email, a.title
FROM areas a
JOIN memberships m ON m.identity_id = a.created_by_identity
                  AND m.tenant_id = a.tenant_id
JOIN identities i ON i.id = m.identity_id
WHERE a.id = $1;
 
-- Problem: if the member LEAVES the tenant, the FK breaks.
-- Do you CASCADE delete all their areas? Probably not.
-- Do you SET NULL? Then you lose authorship history.
-- Memberships are revocable — domain data is not.

With users, domain tables get a stable single-column FK:

-- WITH users: clean single-column FK, decoupled from membership
CREATE TABLE areas (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    created_by UUID NOT NULL REFERENCES users(id),
    title TEXT NOT NULL
);
 
-- Query: "who created this area?"
SELECT i.email, a.title
FROM areas a
JOIN users u ON u.id = a.created_by
JOIN identities i ON i.id = u.identity_id
WHERE a.id = $1;
 
-- If the member leaves the tenant (membership deleted),
-- the users row STAYS. Areas still reference a valid user.
-- The person just can't log in anymore — their historical
-- data remains intact.

The key insight: memberships are revocable (you can leave/be removed from a tenant), but authorship is permanent (you created that area — that’s a historical fact). If domain tables FK to memberships, removing someone from a tenant either cascades deletes into their work or leaves dangling NULLs. The users row is a stable anchor that survives membership changes.

The auto-create pattern

On first login, if an identity has no memberships, auto-create a personal tenant:

if membership_repo.list_for_identity(&identity.id).await?.is_empty() {
    let tenant = tenant_repo.create(&identity.email, &slugify(&identity.email)).await?;
    membership_repo.add(&identity.id, &tenant.id, MemberRole::Owner).await?;
}

This guarantees every identity always has at least one tenant — no empty-state edge cases.

Dual-Strategy Auth in Practice

The most important architectural decision in multi-tenant auth is: when do you trust the token vs when do you hit the database?

The two middleware layers

// Layer 1: IdentityMiddleware (all authenticated endpoints)
// Decodes JWT, upserts identity, injects IdentityContext
// Does NOT verify tenant — for identity-level operations
async fn identity_middleware(req: Request, next: Next) -> Response {
    let claims = decode_jwt(&req)?;
    let identity = identity_repo.upsert_from_login(&claims.sub, &claims.email, ...).await?;
    req.extensions_mut().insert(IdentityContext { identity_id: identity.id });
    next.run(req).await
}
 
// Layer 2: TenantMiddleware (tenant-scoped endpoints)
// Everything above PLUS verifies X-Tenant-ID against JWT claims
// This is the FAST PATH — zero DB queries for tenant verification
async fn tenant_middleware(req: Request, next: Next) -> Response {
    let identity_ctx = req.extensions().get::<IdentityContext>()?;
    let tenant_id = extract_tenant_header(&req)?;
    verify_tenant_in_jwt_claims(&claims, &tenant_id)?;  // fast: no DB
    req.extensions_mut().insert(TenantContext { tenant_id, ... });
    next.run(req).await
}

The decision matrix

Operation typeVerificationRationale
Reads, normal writesJWT claims (fast path)Sub-millisecond. Stale by at most token TTL. Acceptable for non-destructive ops.
”Which orgs am I in?”DB membershipsMust reflect real-time state (just-redeemed invite, just-removed).
Admin actions (invite, kick)DB role lookup + CedarSecurity-critical. A stale JWT could allow a removed admin to still invite.
Membership writes (join, leave)DBBy definition — you’re writing to the memberships table.

Why not always hit the DB?

A membership lookup is ~1ms with an index. For 99% of requests that’s fine. But:

  • At scale (1000s of RPS), it’s 1000 extra queries per second to the DB for information that rarely changes
  • The JWT already carries this information, cryptographically signed
  • Connection pool pressure under load affects all queries, not just auth

The JWT fast path is free — the token is already decoded for authentication. Checking the tenants map is a HashMap lookup in memory. Reserve DB verification for the operations where staleness is actually dangerous.

The staleness window

When membership changes (user joins/leaves a tenant), there’s a window where:

  • The DB reflects the new state immediately
  • The JWT still carries the old state (until token refresh or expiry)

This means:

  • Removed user can still access normal endpoints until their JWT expires (typically 1 hour)
  • Newly joined user can’t access tenant-scoped endpoints until they refresh their token

For Phase 2, you mitigate this with: session revocation via auth provider API (forces re-login on removal) and triggered token refresh (frontend refreshes after joining).

Cedar Role Gating for Admin Operations

The DB-checked path doesn’t just verify membership — it feeds the role into Cedar for fine-grained evaluation:

// In the create_invitation use case:
let role = membership_repo.get_role(&caller_id, &tenant_id).await?;
 
// Build Cedar evaluation context with the DB-sourced role
let ctx = EvaluationContext {
    tier: role.to_string(),  // "owner", "admin", etc.
    action: "org:invite".to_string(),
    groups: role_to_groups(&role),  // owner/admin → ["admins"]
    ...
};
 
let allowed = evaluator.evaluate(&policies, &ctx)?;

This connects two patterns:

  • Attribute-based: principal.role == "owner" — flat string check
  • Group-based: principal in TenantGroup::"admins" — entity hierarchy

Both work. Group-based is more maintainable (add a new “super-admin” role to the “admins” group without touching policies). Attribute-based is more explicit (you see exactly which role is required in the policy text).

See also