- Active Tenant Scoping vs Access Control: A Practical Guide
Active Tenant Scoping vs Access Control: A Practical Guide
Why active tenant scoping matters: prevent wrong-tenant reads, enforce tenant filters, and secure multi-tenant…

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryRelated Posts:
Most developers I talk to think access control and tenant scoping are basically the same thing. That mental model works until a user belongs to multiple tenants. Then it fails in ways that are hard to spot, because the system is still "secure" in one sense, but wrong in another.
This article is about that exact gap: access control decides what a user is allowed to do, while tenant scoping decides which tenant context they are doing it in right now. If your users can switch between companies under one account, this distinction is critical.
What this actually is
Access control is a permission boundary. It answers:
"Can this user read or write this resource at all?"
Tenant scoping is a runtime context boundary. It answers:
"Which tenant should this query, page, or workflow be operating on right now?"
In a multi-tenant setup where one user can belong to several tenants, both checks can be true at once:
- User is allowed to read tenant A and tenant B data.
- Current UI context is tenant A.
- Query forgets tenant filter.
- User sees tenant B records anyway.
Nothing "broke" from an access-control perspective. But tenant intent is violated.
That is the core bug class.
Mental model
Think of access control as your building badge, and tenant scoping as your floor lock.
Your badge may let you enter floors 2, 3, and 4. That is access control.
But if your current task is on floor 3, every elevator stop in that flow must still be locked to floor 3. That is tenant scoping.
If one stop in the flow forgets the floor lock, you are still in an authorized building, but in the wrong place.
In code terms:
// Access only: valid but ambiguous for multi-tenant users
await payload.find({
collection: "documents",
overrideAccess: false,
user,
});
// Access + active-tenant scope: explicit and correct
await payload.find({
collection: "documents",
where: { tenant: { equals: activeTenantId } },
overrideAccess: false,
user,
});
The second query encodes current intent, not just global permission.
When to use strict active-tenant scoping
You should apply active-tenant scoping on every tenant-sensitive read/write path when users can access multiple tenants.
In practice, that includes:
- List pages (
/settings/*,/browse,/review) - Detail pages (
/documents/[id], scoped route variants) - Edit pages (with hard not-found guard for cross-tenant IDs)
- Lookup helpers used by jobs and workflows
In this codebase, those were pages such as:
src/app/(frontend)/settings/ai-prompts/page.tsxsrc/app/(frontend)/settings/jobs/page.tsxsrc/app/(frontend)/documents/[id]/page.tsxsrc/app/(frontend)/cost-objects/[code]/[[...specificID]]/page.tsx
Each one now resolves active tenant from payload-tenant and enforces tenant filters directly in query where clauses.
When not to over-apply it
Not every operation should be forced into a single-tenant view.
For example, certain operational surfaces may intentionally need cross-tenant visibility for assigned tenants (like controlled MCP flows for an owner-level user). In those cases, the tenant scope should still be explicit, just different:
- selected single tenant (
tenantId) - explicit multi-tenant set (
tenantIds[]) - clearly-audited "all assigned tenants" mode
What you want to avoid is implicit behavior. "Whatever access control returns" is not a scope strategy.
Gotchas that usually cause the bug
The first gotcha is trusting UI state more than query state. You can filter sidebar navigation correctly and still leak records if route-level queries are not tenant-filtered. The UI may look tenant-aware while data fetching is not.
The second gotcha is background-job context. In worker execution there may be no frontend user session, which changes access behavior. In src/lib/pipeline/get-prompt.ts, prompt resolution needed overrideAccess: true for job context, but still had to preserve tenant filtering by { taskStep, tenant, isActive }. If you fix only access and not tenant filter, you trade one bug for another.
The third gotcha is route-param trust. A URL like /documents/123 or /settings/.../[id]/edit should not assume ownership from ID alone. You need a post-fetch tenant check and a hard fail (notFound) when record tenant does not match active tenant.
Why this mattered in our case
We had a user with multiple tenant memberships and active tenant switching via cookie. Access checks were valid, but some paths were not explicitly tenant-scoped. That created wrong-tenant reads and confusing behavior, including prompt resolution mismatches in pipeline execution.
The key lesson was simple: collection access rules protect capability; tenant filters protect intent.
If your platform supports one account across multiple companies, this distinction is not optional architecture polish. It is baseline correctness.
Implementing it in practice
The conceptual model is clear enough. Here is what the implementation actually looks like across the three surfaces that matter.
Resolving active tenant on the server
Every tenant-sensitive page or handler needs to start from the same source: the payload-tenant cookie. The plugin ships a getTenantFromCookie utility that reads this from request headers, but in Next.js App Router server components you will typically resolve it through the Next.js cookies() API instead.
A thin resolver helper keeps this consistent across all pages:
// File: src/lib/tenant/resolveActiveTenantId.ts
import { cookies } from 'next/headers'
export async function resolveActiveTenantId(): Promise<string | null> {
const cookieStore = await cookies()
return cookieStore.get('payload-tenant')?.value ?? null
}
Every page that needs tenant context calls this first. If it returns null, you either redirect to a tenant-selection screen or return a scoped empty state — but you never proceed to query without it.
List pages
List pages are the most common leak point. The query runs, access control passes, and records from other tenants show up because no where filter was applied.
The fix is mechanical once you have activeTenantId:
// File: src/app/(frontend)/settings/ai-prompts/page.tsx
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { resolveActiveTenantId } from '@/lib/tenant/resolveActiveTenantId'
export default async function AIPromptsPage() {
const activeTenantId = await resolveActiveTenantId()
if (!activeTenantId) {
return <EmptyState message="No active tenant selected." />
}
const payload = await getPayload({ config: configPromise })
const prompts = await payload.find({
collection: 'ai-prompts',
where: { tenant: { equals: activeTenantId } },
overrideAccess: false,
user,
})
return <PromptList docs={prompts.docs} />
}
The where clause is the entire fix. Access control still runs via overrideAccess: false — you are not bypassing permissions, you are adding an intent filter on top of them.
This same pattern applies directly to every settings list page: document types, cost objects, cost object types, jobs. Same resolver, same where clause, different collection.
Edit and detail pages
List pages are obvious. Edit pages are where developers usually miss the second check.
Fetching /settings/ai-prompts/123/edit by ID looks fine. The record exists, access control passes, the page loads. But if record 123 belongs to tenant B and the active tenant is tenant A, you have just served the wrong tenant's data to an authorized user.
The fix requires a post-fetch ownership check:
// File: src/app/(frontend)/settings/ai-prompts/[id]/edit/page.tsx
import { notFound } from 'next/navigation'
import { resolveActiveTenantId } from '@/lib/tenant/resolveActiveTenantId'
export default async function EditPromptPage({ params }: { params: { id: string } }) {
const activeTenantId = await resolveActiveTenantId()
if (!activeTenantId) notFound()
const payload = await getPayload({ config: configPromise })
const prompt = await payload.findByID({
collection: 'ai-prompts',
id: params.id,
overrideAccess: false,
user,
})
if (!prompt) notFound()
// Hard cross-tenant guard
const promptTenantId =
typeof prompt.tenant === 'object' ? prompt.tenant.id : prompt.tenant
if (String(promptTenantId) !== String(activeTenantId)) notFound()
return <EditForm prompt={prompt} />
}
The notFound() call after the tenant mismatch check is intentional. You do not want to tell the user "forbidden" — that leaks that the record exists. You want the resource to simply not exist from their current tenant context.
This pattern covers all four edit routes: ai-prompts, document-types, cost-objects, cost-object-types.
Background jobs
Background workers break the pattern in a specific way: there is no frontend user session, so standard access checks may hide records that definitely exist. The fix for that is overrideAccess: true. But that fix alone creates a new problem — if you remove the access check without adding a tenant filter, you now query across all tenants.
This is exactly what happened with prompt resolution in the pipeline. The query was:
// File: src/lib/pipeline/get-prompt.ts — before fix
const result = await payload.find({
collection: 'ai-prompts',
where: { taskStep: { equals: taskStep }, isActive: { equals: true } },
})
This silently matched a prompt from the wrong tenant. The job ran, the wrong prompt executed, and the workflow produced incorrect output. No error was thrown because the record existed and access was not denied.
The correct pattern for job context:
// File: src/lib/pipeline/get-prompt.ts — after fix
const result = await payload.find({
collection: 'ai-prompts',
where: {
taskStep: { equals: taskStep },
tenant: { equals: tenantId },
isActive: { equals: true },
},
overrideAccess: true,
depth: 0,
sort: '-updatedAt',
limit: 1,
})
overrideAccess: true fixes the auth gap for job context. The explicit tenant filter restores the intent boundary. The sort: '-updatedAt' with limit: 1 makes selection deterministic if multiple active prompts exist for the same step.
The rule is: when you reach for overrideAccess: true, immediately check whether the tenant filter is still explicit. Removing one guard does not justify removing both.
Relationship and dropdown fields
The last surface most implementations miss is scoped dropdowns. An edit form may correctly scope its main record fetch, but the relationship field options — parent category, type selector, linked record — are often populated with a separate unscoped query.
The result: the form loads for tenant A, but the type dropdown shows options from tenant B as well.
The fix is the same where filter applied to every options query:
// File: src/app/(frontend)/settings/cost-objects/[id]/edit/page.tsx
const [costObject, types, parents] = await Promise.all([
payload.findByID({ collection: 'cost-objects', id: params.id, overrideAccess: false, user }),
payload.find({
collection: 'cost-object-types',
where: { tenant: { equals: activeTenantId } },
overrideAccess: false,
user,
}),
payload.find({
collection: 'cost-objects',
where: { tenant: { equals: activeTenantId } },
overrideAccess: false,
user,
}),
])
Every query that produces selectable options must be tenant-scoped independently. The main record guard and the options fetch are separate operations — you have to apply the filter to both.
Why this mattered in our case
We had a user with multiple tenant memberships and active tenant switching via cookie. Access checks were valid, but some paths were not explicitly tenant-scoped. That created wrong-tenant reads and confusing behavior, including prompt resolution mismatches in pipeline execution.
The key lesson was simple: collection access rules protect capability; tenant filters protect intent.
If your platform supports one account across multiple companies, this distinction is not optional architecture polish. It is baseline correctness.
Conclusion
Access control and active tenant scoping are not interchangeable. Access control defines what a user may do across their assignments. Tenant scoping defines which tenant context applies to this exact request.
Apply the resolver at the top of every tenant-sensitive handler. Add explicit where filters to list queries. Add a post-fetch ownership check to every edit and detail route. When using overrideAccess: true in background jobs, always verify the tenant filter is still explicit. Scope relationship dropdown options the same way you scope the main record.
If you have questions or ran into a different gotcha, drop a comment below. And if you found this useful, subscribe for more.
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.


