Heimdall guides
04 · Tenants

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_id claim, 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_at

3

Create a tenant

Two lanes again — workspace owner via Heimdall-admin, or customer-product backend via Consumer admin (PAK with heimdall.tenant.create).

POST /<appSlug>/v1/tenants (PAK)
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.

POST /<appSlug>/v1/tenants/:id/members
# 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 an org_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.

POST /<appSlug>/v1/auth/switch-tenant
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.

GET /<appSlug>/v1/auth/me/tenants
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.deleted
  • tenant.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 admin in 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_id on 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.read inside one tenant, demote their app-level role — there's no tenant-level deny.