- 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…

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryIf 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.
In a multi-tenant app, these concerns should not be mixed together:
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:
createdByupdatedByThose 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.
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.
users from the Multi-Tenant PluginThe 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.
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.
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.
users Work Normally AgainOnce 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.
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.
If the same human can work across multiple tenants, your users collection should not be tenant-scoped.
Use:
users collection for identitytenants membership array for access controlOnce you model it that way, the code becomes much easier to reason about, and fields like createdBy stop breaking for perfectly valid users.
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
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.