Model your customers' multi-tenancy.
When the people using your product belong to teams / organisations / workspaces that need separated data, Heimdall has a built-in primitive for it: tenants. This chapter covers when to use them, how the JWT carries the active-tenant context, and how to switch tenants in-session.
1
When do you need tenants?
Two product shapes you might be building:
- Flat consumer product. Each user is their own boundary. Examples: a journaling app, a meditation app. No tenants. The EndUser's account holds everything they own.
- Org-shaped product. A user belongs to one or more organisations / teams / workspaces (call it what you like in your UI). Data is isolated per org. Examples: a project management tool, a billing platform. Use tenants.
If your product is org-shaped, you have two choices:
- Tenants in Heimdall. Heimdall manages the membership graph, your JWT carries an
org_idclaim, and your permission resolver unions app-role + tenant-role automatically. - Roll your own. Heimdall just owns identity; your product DB owns the org graph. JWTs only carry the user's app role.
Heimdall's tenants are right when (a) you want a UI primitive for "switch organisation" without a full re-auth, and (b) you want different permissions per-tenant for the same user (admin in one tenant, member in another). Both cases are common; tenants are a real lift but they save a chunk of identity-graph code you'd otherwise write.
2
Anatomy of a tenant
A tenant has a display_id (tnt_<12hex>) for nice copy-paste, a slug + display name your UI shows, optional metadata, and a list of members each with a role.
The schema, at a glance:
tenant
id uuid
display_id "tnt_a1b2c3d4e5f6" (derived from id, short form for UIs)
app_id FK → app (tenants are app-scoped)
slug "acme-corp"
display_name "Acme Corp"
status active | suspended | deactivated
metadata jsonb
tenant_membership
tenant_id FK
account_id FK → account
role_id FK → role (any role in the same app)
joined_at3
Create a tenant
Two lanes again — workspace owner via Heimdall-admin, or customer-product backend via Consumer admin (PAK with heimdall.tenant.create).
curl https://api.heimdall.productcraft.co/acme/v1/tenants \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"slug": "globex",
"display_name": "Globex Corporation",
"metadata": { "plan": "team", "seats": 25 }
}'{
"id": "f9b3...",
"display_id": "tnt_a1b2c3d4e5f6",
"slug": "globex",
"display_name": "Globex Corporation",
"status": "active",
"metadata": { "plan": "team", "seats": 25 },
"created_at": "2026-05-11T..."
}4
Add a member
Two shapes: pass accountId for an existing EndUser, or pass email + optional username to inline-create the account.
# Existing EndUser
curl https://api.heimdall.productcraft.co/acme/v1/tenants/f9b3.../members \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "accountId": "648616c8-...", "role": "admin" }'
# New EndUser inline
curl https://api.heimdall.productcraft.co/acme/v1/tenants/f9b3.../members \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"email": "newhire@globex.com",
"username": "newhire",
"displayName": "New Hire",
"role": "member"
}'The inline-create path creates the account without a password. The customer triggers a password-reset flow to onboard (Chapter 7).
5
The org_id JWT claim
When the user has signed in 'into' a tenant, their access token carries org_id + org_role. Your routes can read these directly without an extra round-trip.
The claim layout:
{
"sub": "648616c8-...", // EndUser id
"aid": "53641fdb-...", // app id
"role": "member", // app-level role
"type": "end_user",
"org_id": "f9b3...", // active tenant id (if any)
"org_role": "admin", // role inside that tenant
"iss": "heimdall",
"exp": ...
}Your service reads the token, sees org_id, scopes the query to that tenant. That's the mechanism — there's no separate "active tenant" sidecar to fetch on every request.
6
Tenant-scoped role overlay
A user's effective permissions are the union of:
- The app-level role's permissions (from
app_membership.role), AND - The tenant-level role's permissions (from
tenant_membership.role) — only when the token carries anorg_id.
Concretely: Ada is a member globally (read-only) but admin inside Globex Corp. When her token carries org_id = globex, her effective set is member ∪ admin = admin permissions. When she switches out of Globex (org_id absent), she's back to member.
This is computed live every request via the RolePermissionResolver (60s LRU). Role-permission edits propagate within a minute.
7
Switch the active tenant
A user with memberships in multiple tenants picks which one is active by calling switch-tenant. Fresh tokens get issued with the new org_id claim; the session row updates so refreshes preserve the context.
curl https://api.heimdall.productcraft.co/acme/v1/auth/switch-tenant \
-H 'authorization: Bearer <current-access-token>' \
-H 'content-type: application/json' \
-d '{ "tenantId": "f9b3..." }'Response is a fresh access + refresh token pair, just like signin. Refresh-token rotation runs as usual; the previous refresh token enters the standard 60s grace window.
Pass tenantId: null to switchout of all tenants — useful for “Personal workspace” or “Account settings” screens where tenant context should be off.
8
List the user's tenants
To render a tenant-switcher UI, your frontend asks Heimdall for the current user's memberships.
curl https://api.heimdall.productcraft.co/acme/v1/auth/me/tenants \
-H 'authorization: Bearer <access-token>'{
"data": [
{ "id": "f9b3...", "display_id": "tnt_...", "slug": "globex", "display_name": "Globex Corp", "role": "admin" },
{ "id": "01a4...", "display_id": "tnt_...", "slug": "acme", "display_name": "Acme Inc", "role": "member" }
]
}9
Webhooks fire on tenant lifecycle events
Tenant create / update / delete and member add / remove / role-change all fire webhook events. Useful for syncing the tenant graph into your product DB or for audit.
Event names:
tenant.created,tenant.updated,tenant.deletedtenant.member.added,tenant.member.role_changed,tenant.member.removed
Chapter 8 covers the webhook configuration + signature verification.
10
Common pitfalls
- Don't confuse tenant with app. One app = one product. Tenants are inside the app, modelling your customers' orgs. If you find yourself making a new app per customer, you probably want tenants.
- Tenant role ≠ workspace role. The role graph is per-app, shared across tenants. The same role named
adminin app Acme means the same thing inside Globex and Wayne Enterprises (both tenants of Acme). - Plan for users with no active tenant. Some routes (account settings, billing) should work regardless of tenant context. Your app-level role gates those. Don't require
org_idon every request. - Org_role overrides nothing. The tenant role grants in addition to the app role; it cannot revoke an app-level permission. If a user shouldn't hold
billing.readinside one tenant, demote their app-level role — there's no tenant-level deny.