Olympus Docs
CookbookMulti-tenant

Multi-tenant with soft isolation

Single Olympus instance, multiple tenants by trait

You're building a B2B SaaS. Each customer ("tenant") has their own users, but you don't want to run a separate Olympus stack per tenant. The "soft isolation" pattern: one stack, tenant-tagged identities.

For hard isolation (separate DBs per tenant) see Hard isolation.

Identity model

Each identity has a tenant_id trait:

// identity.schema.json
"traits": {
  "type": "object",
  "properties": {
    "email": { "type": "string", "format": "email" },
    "tenant_id": { "type": "string", "format": "uuid" }
  },
  "required": ["email", "tenant_id"]
}

Registration

Tenants are typically created via your own app's "create company" flow, not by Olympus. When inviting users:

async function inviteUser(tenantId: string, email: string) {
  // Create recovery flow that pre-fills tenant_id
  const inv = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
    method: "POST",
    body: JSON.stringify({
      schema_id: "default",
      traits: { email, tenant_id: tenantId },
      state: "active",
    }),
  });
  // Send invitation to recover their password
  await sendRecoveryEmail(email);
}

The user clicks the recovery link → sets password → login → session has tenant_id (via session traits).

App-level isolation

Your app must enforce that tenant A users can never read tenant B data. Two approaches:

Approach A: Tenant in JWT claim

Customize Hydra session to include tenant_id:

// hydra session-customizer or your consent app
function shapeSession(session) {
  const identity = await kratos.adminGetIdentity(session.identity.id);
  return {
    ...session,
    id_token: { tenant_id: identity.traits.tenant_id, ...session.id_token },
    access_token: { tenant_id: identity.traits.tenant_id, ...session.access_token },
  };
}

Your API extracts tenant_id from token, filters every query.

Approach B: Tenant in your DB

App's DB has tenants too:

CREATE TABLE tenants (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE user_tenant_membership (
  identity_id UUID NOT NULL,
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  role TEXT NOT NULL,
  PRIMARY KEY (identity_id, tenant_id)
);

On login, your app sets the active tenant in the session cookie. Switching tenants = updating the cookie.

This pattern allows a user to be in multiple tenants (more flexible).

Subdomain routing

Common pattern: tenant-name.your-app.com.

DNS

Wildcard CNAME:

*.your-app.com → your-app.com

App-level

// Middleware
const subdomain = req.hostname.split(".")[0];
const tenant = await db`SELECT * FROM tenants WHERE slug = ${subdomain}`.first();
if (!tenant) return res.status(404);
req.tenantId = tenant.id;

Auth scoping per subdomain

If you want acme.your-app.com to only allow Acme employees:

  1. After OAuth2 callback, check JWT's tenant_id matches subdomain's tenant.
  2. If not match, redirect to "wrong tenant" page.
if (jwt.tenant_id !== req.tenantId) {
  return res.redirect("/wrong-tenant");
}

Note: this is enforcement on your app, not on Olympus. Olympus is happy to issue a token for any identity.

Inviting / removing users

Admin UI in your app:

// Invite
async function invite(tenantId, email, role) {
  // Use Athena admin API or directly Kratos:
  const identity = await kratos.adminCreate({
    schema_id: "default",
    traits: { email, tenant_id: tenantId },
  });
  await db`INSERT INTO user_tenant_membership (identity_id, tenant_id, role) VALUES (${identity.id}, ${tenantId}, ${role})`;
  await sendInvitationEmail(email);
}

// Remove
async function removeFromTenant(identityId, tenantId) {
  await db`DELETE FROM user_tenant_membership WHERE identity_id=${identityId} AND tenant_id=${tenantId}`;
  // If user has no other tenants, you might also delete the identity
  const remaining = await db`SELECT COUNT(*) FROM user_tenant_membership WHERE identity_id=${identityId}`;
  if (remaining[0].count === 0) await kratos.adminDelete(identityId);
}

Branding per tenant

If acme.your-app.com needs Acme's logo on the login page, customize Hera:

// hera/middleware.ts
import { NextResponse } from "next/server";
export function middleware(req) {
  const sub = req.nextUrl.hostname.split(".")[0];
  const tenantBrand = getTenantBrand(sub);
  const res = NextResponse.next();
  res.headers.set("X-Tenant-Brand", JSON.stringify(tenantBrand));
  return res;
}

Hera reads the header server-side, renders with tenant logo/color.

What this doesn't isolate

Soft isolation gives you logical separation but NOT:

  • Database-level RLS (use [hard isolation] for that).
  • Encryption-key-per-tenant.
  • Compliance-grade "data physically separate."

Customers who need hard isolation (regulated industries) won't accept soft. Match the pattern to your customers.

On this page