---
title: "Why Payload CMS Users Should Never Be Tenant-Scoped"
slug: "payload-cms-users-not-tenant-scoped"
published: "2026-04-18"
updated: "2026-04-06"
validated: "2026-03-06"
categories:
  - "Payload"
tags:
  - "Payload CMS users"
  - "tenant-scoped users"
  - "multi-tenant Payload CMS"
  - "createdBy validation error"
  - "tenant membership array"
  - "user.tenants"
  - "multi-tenant plugin"
  - "tenant-scoped relationships"
  - "global users collection"
  - "payload.config.ts"
  - "relationship validation"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload@2 (inferred)"
  - "node@20 (inferred)"
  - "typescript@5 (inferred)"
  - "multi-tenant-plugin@latest (community/custom)"
status: "stable"
llm-purpose: "Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to TypeScript"
  - "Access to Node.js"
  - "Access to multi-tenant plugin"
  - "Access to payload.config.ts"
llm-outputs:
  - "Completed outcome: Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…"
---

**Summary Triples**
- (users collection, should be, global (not tenant-scoped) to represent identity across tenants)
- (tenant access control, should be enforced with, a tenant-membership array on user records (e.g., user.tenants))
- (tenant-scoped users, cause, relationship validation errors (createdBy, updatedBy, comments, audit trails) when referenced across tenants)
- (clean fix, is, register the users collection as global / remove it from the plugin's tenant-scoped registration)
- (migration, requires, adding a tenants array to existing user docs and updating relationships to reference the global user ids)
- (best-practice modeling rule, states, identity = global, authorization = tenant-specific, business data = tenant-scoped)
- (fields to inspect when debugging, include, createdBy, updatedBy, comments, notes, and audit trail relationship fields)

### {GOAL}
Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…

### {PREREQS}
- Access to Payload CMS
- Access to TypeScript
- Access to Node.js
- Access to multi-tenant plugin
- Access to payload.config.ts

### {STEPS}
1. Remove users from tenant-scoped collections
2. Store tenant membership on user
3. Derive access from memberships only
4. Restore normal user relationships

<!-- llm:goal="Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to multi-tenant plugin" -->
<!-- llm:prereq="Access to payload.config.ts" -->
<!-- llm:output="Completed outcome: Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…" -->

# Why Payload CMS Users Should Never Be Tenant-Scoped
> Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…
Matija Žiberna · 2026-04-18

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:

- `createdBy`
- `updatedBy`
- 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.

```ts
// 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.

```ts
// 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.

```ts
// 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.

```ts
// 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 `users` collection for identity
- a `tenants` membership 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

## LLM Response Snippet
```json
{
  "goal": "Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…",
  "responses": [
    {
      "question": "What does the article \"Why Payload CMS Users Should Never Be Tenant-Scoped\" cover?",
      "answer": "Payload CMS users must be global — not tenant-scoped. Follow this step-by-step fix to stop createdBy relationship errors and enforce access through…"
    }
  ]
}
```