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.comApp-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:
- After OAuth2 callback, check JWT's
tenant_idmatches subdomain's tenant. - 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.