- Why Payload CMS Users Should Never Be Tenant-Scoped
Why Payload CMS Users Should Never Be Tenant-Scoped
Make identity global in multi-tenant Payload CMS: use tenant membership arrays to prevent createdBy validation errors…

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryRelated Posts:
If you are building a multi-tenant Payload CMS app, it is very easy to make one architectural mistake that looks reasonable at first and then causes strange validation errors later: making the users collection tenant-scoped.
I hit this exact issue while fixing a note creation flow. A super-admin could switch tenants, open the correct document, submit the note, and still get a validation error on createdBy. The user was authenticated. The document belonged to the active tenant. The action had the correct IDs. The bug was not in the server action. The bug was in the data model.
The real issue was simple: one person could work across multiple tenants, but the users collection itself had been registered as tenant-scoped in the multi-tenant plugin. That meant the related user record still belonged to one primary tenant, and Payload rejected cross-tenant relationships that pointed back to that user.
This guide shows the clean fix and the rule you should keep in mind going forward.
The Modeling Rule
In a multi-tenant app, these concerns should not be mixed together:
- Identity is global.
- Authorization is tenant-specific.
- Business data is tenant-scoped.
That means the users collection should usually be global. It represents the person logging in, not the business entity they are currently working inside.
Tenant access should be controlled through membership data such as user.tenants, not by tenant-scoping the user record itself.
If you break that rule, the bug often appears later in relationship fields like:
createdByupdatedBy- comments
- notes
- audit trails
Those fields start failing because the related users document is attached to the wrong tenant scope, even when the same person is legitimately allowed to access multiple tenants.
What Goes Wrong
The bug becomes obvious once you look at the data shape.
Imagine the session is operating inside tenant 2, but the related user document is tenant-scoped to tenant 1. If a note in tenant 2 tries to store createdBy as that user, Payload validates the relationship against the tenant-scoped user record and rejects it.
That is why the error feels confusing. The user exists. The user is logged in. The user may even be a super-admin. But the relationship still fails because the related document is scoped differently.
This is not really an access control problem. It is a schema problem.
Step 1: Remove users from the Multi-Tenant Plugin
The first fix is in your Payload config. Do not register users as a tenant-scoped collection.
// File: payload.config.ts
multiTenantPlugin<Config>({
enabled: true,
cleanupAfterTenantDelete: true,
tenantsSlug: "tenants",
useUsersTenantFilter: true,
tenantsArrayField: {
includeDefaultField: true,
arrayFieldName: "tenants",
arrayTenantFieldName: "tenant",
},
tenantField: getTenantFieldConfig(),
userHasAccessToAllTenants: (user: any) => isSuperAdmin(user),
collections: {
[AiPrompts.slug]: {},
[DocumentTypes.slug]: {},
[CostObjectTypes.slug]: {},
[CostObjects.slug]: {},
[GoogleConnections.slug]: {},
[IngestionJobs.slug]: {},
[Documents.slug]: {},
[DocumentNotes.slug]: {},
[DocumentFiles.slug]: {},
[PipelineRuns.slug]: {},
[ReviewTasks.slug]: {},
[AuditEvents.slug]: {},
[OcrResults.slug]: {},
[Logs.slug]: {},
},
})
The key detail here is what is missing: Users.slug.
Once users is removed from that collections map, Payload no longer injects a tenant scope into the users collection. That makes the user identity global again, which is what you want when the same person can legitimately work across multiple tenant workspaces.
Step 2: Keep Tenant Membership on the User
Removing tenant scoping from users does not mean users can suddenly access every tenant. It just means the user record is global.
The access restrictions should still come from the membership array on the user document.
// File: payload.config.ts
await payload.create({
collection: "users",
overrideAccess: true,
data: {
email: "matija@we-hate-copy-pasting.com",
password: "Matija113!",
first_name: "Matija",
last_name: "Admin",
roles: ["super-admin"],
tenants: [
{
tenant: tenant.id,
roles: ["admin"],
},
],
},
})
The important change is that the seed no longer writes a primary tenant field on the user. Instead, the user only stores tenant membership inside tenants.
That keeps the user global while still preserving exactly which tenants they are allowed to access.
Step 3: Resolve Access from Membership Only
Once users is global, any helper that reads tenant access from user.tenant should be updated. The source of truth should be the tenants membership array.
// File: src/payload/utilities/get-user-tenant-ids.ts
import type { Tenant, User } from "@payload-types"
import { extractID } from "payload/shared"
export const getUserTenantIDs = (
user: null | User,
role?: NonNullable<User["roles"]>[number],
): Tenant["id"][] => {
if (!user) {
return []
}
const tenantIds =
user?.tenants?.reduce<Tenant["id"][]>((acc, { tenant, roles }) => {
if (role && !roles?.includes(role as any)) {
return acc
}
if (tenant) {
acc.push(extractID(tenant))
}
return acc
}, []) || []
return Array.from(new Set(tenantIds))
}
This helper now does one thing only: it derives tenant IDs from explicit memberships. That is the correct boundary. The user record identifies the person. The membership list defines where that person is allowed to work.
Step 4: Let Relationships to users Work Normally Again
Once users is global, author relationships become straightforward again.
// File: src/payload/collections/documents/document-notes/index.ts
hooks: {
beforeValidate: [
async ({ data, operation, req }) => {
if (operation !== "create") {
return data
}
if (!req.user) {
throw new APIError("Unauthorized", 401)
}
const nextData = { ...(data as Record<string, unknown>) }
const documentId = relationId(nextData.document)
if (documentId == null) {
throw new APIError("Document is required", 400)
}
const document = (await req.payload.findByID({
collection: "documents",
id: documentId,
depth: 0,
overrideAccess: false,
user: req.user,
})) as unknown as Document
const tenantId = relationId(document.tenant)
if (tenantId == null) {
throw new APIError("Document tenant is required", 400)
}
nextData.document = documentId
nextData.tenant = tenantId
nextData.createdBy = relationId(req.user.id)
return nextData
},
],
}
There is no special-case workaround here. The note hook simply sets the current authenticated user as createdBy.
That works because the user is no longer tenant-scoped. The relationship points to a global identity record, while the note itself remains tenant-scoped business data.
Why This Is a Common Gotcha
This mistake happens because the first instinct in a multi-tenant app is often: "Everything should be tenant-scoped."
That instinct is correct for business data. It is not correct for identity when one person can work across multiple tenants.
The reason this becomes such a common trap is that the initial setup often looks fine. Login works. Tenant switching works. Collections load. The bug only appears later when you introduce cross-tenant relationships back to users.
That delayed failure is what makes this one so frustrating.
The Practical Rule to Keep
If the same human can work across multiple tenants, your users collection should not be tenant-scoped.
Use:
- a global
userscollection for identity - a
tenantsmembership array for access control - tenant-scoped collections for actual business records
Once you model it that way, the code becomes much easier to reason about, and fields like createdBy stop breaking for perfectly valid users.
Conclusion
The issue here was not a missing ID, a broken server action, or a strange Payload bug. The real problem was that the data model treated users as tenant-owned data even though the same user could operate across multiple tenants.
The fix was to make users global again, keep tenant restrictions in user.tenants, and leave tenant scoping to the real business collections. As soon as that boundary was restored, cross-tenant author relationships worked normally again.
If you are building a multi-tenant Payload CMS app, this is one rule worth remembering early: keep identity global, keep authorization tenant-specific, and keep business data tenant-scoped.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija
📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
Frequently Asked Questions
Comments
No comments yet
Be the first to share your thoughts on this post!


