Intermediate May 7, 2026 · 3 min read

When a single Loom app serves multiple organisations (each with their own users, customers, invoices), every read and write must stay inside the active org's data. Loom expresses this with a single Thread-level flag — tenant_scoped: true — and the runtime handles the rest.

The flag

Add tenant_scoped: true and a tenant_id field that links to a Tenant Thread:

model: Customer
label: Customer
icon: users
tenant_scoped: true

fields:
  - name: tenant_id
    type: link
    label: Tenant
    required: true
    target: Tenant
  - name: company_name
    type: text
    required: true

The validator enforces that tenant_scoped: true ships with a matching link field (named tenant_id by default; override with tenant_key:) targeting Tenant and marked required: true. Forget any of those and loom check fails.

What the generator does

For every tenant-scoped Thread, the generated handlers:

Operation What changes
List WHERE tenant_id = ? injected from auth.MustTenantFromContext(ctx)
Get WHERE id = ? AND tenant_id = ?
Create Sets record.TenantId = &tenantID server-side. The client body's tenant_id is ignored — clients can't move a record into a different tenant by lying.
Update Pins tenant_id from context AND adds WHERE id = :id AND tenant_id = :tenant_id. A stale row from a different tenant won't be silently rewritten.
Delete WHERE id = ? AND tenant_id = ?

This is structural: cross-tenant access becomes impossible at the SQL layer, not merely role-permission-checked. A bug in role logic can't leak rows because the SQL never returns them.

Where the tenant comes from

The runtime expects a JWT with a tnt claim. The User struct in internal/runtime/auth.go has a Tenant uuid.UUID field; Auth.Issue(u) includes the tnt claim when u.Tenant != uuid.Nil. From a handler:

tenantID := auth.MustTenantFromContext(ctx)

Returns the active tenant's UUID, panics if absent. The panic is intentional: a tenant-scoped handler reaching this line without a tnt claim means the auth middleware is misconfigured — that's a 500 (caught by chi's recoverer), not a silent fallback to "give them every tenant's data".

For optional checks (handlers that may run for platform-level users with no tenant), use the soft variant:

if tenantID, ok := auth.TenantFromContext(ctx); ok {
    // tenant-aware path
}

Getting a tenant-scoped JWT

/auth/login issues a token with tnt empty — the user has authenticated but hasn't picked a tenant. Tenant-scoped routes will 500 (panic) on that token because MustTenantFromContext finds nothing.

/auth/switch-tenant exchanges that token for one with tnt set, after verifying the user is a TenantMember of the requested tenant. See Reference → Auth Endpoints for the full request/response shape.

What's NOT tenant-scoped

Some Threads are platform-level by design and should NOT carry tenant_scoped: true:

  • User — users belong to many tenants (one user record, many TenantMember rows). Scoping User by tenant breaks the cross-tenant identity model.
  • Tenant — the tenant itself. Self-referential scoping makes no sense.
  • TenantMember — the join Thread. Its scoping decision lives at a higher policy layer (e.g., "only platform admins can list every membership; tenant owners list their own tenant's memberships"), not per-row CRUD.

apps/core/ ships these three plus four genuinely tenant-scoped Threads (LLMCredential, Agent, AgentRun, AuditEvent) as a reference layout.

When to use it vs. when to skip

Use tenant_scoped: true for any Thread holding tenant-private data — Customer, Invoice, AgentRun, AuditEvent, LLMCredential, etc.

Skip it for:

  • Cross-tenant data (User, Tenant, public catalog tables, shared reference data).
  • Single-tenant apps. The flag is harmless to leave off; queries stay simple SELECT * FROM … without the WHERE tenant_id = ? clause.
  • Threads where tenant-scoping is too coarse — e.g., a "shared workspace" model. Implement that with a custom permission layer rather than tenant_scoped.

Indexes you'll want eventually

Loom emits a KEY index on every searchable: true field. For a tenant-scoped Thread querying with WHERE tenant_id = ? AND status = ? you'll want a compound (tenant_id, status) index, which Loom doesn't emit yet. Add it manually via a migration or as a loom stitch step once the flag is stable. Tracked in TASKS.md.

See also

  • Concepts → Permissions — role-based gating layered on top of tenant scoping
  • Concepts → Secret Fields — frequently used together (e.g., LLMCredential.encrypted_key)
  • Reference → Auth Endpoints/auth/login + /auth/switch-tenant mechanics
Was this article helpful?