Tenant scoping that fails closed, permissions that know about branches, and the tests that keep both honest.
Key takeaways
- Resolve the tenant from the session, never from the request. A URL slug or header can select which of your memberships to use, but the server must verify that membership.
- Next.js middleware is a gate, not a guard. Use it for redirects and cheap checks; do real authorization next to the data, in your Node API or server code.
- Every Server Action and Route Handler is a public endpoint. Authorize inside each one, every time.
- Make the database refuse cross-tenant reads. PostgreSQL row-level security with a transaction-local setting means a forgotten filter returns nothing instead of someone else's data.
- Permissions are
(action, scope), not role names. "Can approve purchases in branch 3" is the unit of authorization, and transfers check both ends.- Write the attacker's tests first: cross-tenant by ID, cross-branch, self-escalation, removing the last owner, and redacted fields in raw JSON.
I'm Mohammadreza Alamnejad, a programmer and network security professional, UI/UX designer and developer, and the founder of Arka Studio in Tehran. My studio built NABZA, an operations platform for cafés and restaurants with multiple organizations, branches and warehouses. Its public security page describes the principles this post is about: rows locked to their organization with row-level security, branch and warehouse scope checked on sensitive actions, the last owner can't be removed, and nobody can grant themselves more permissions.
The code below is a reference architecture I'd recommend for this kind of product, not NABZA's source code. It uses Next.js (App Router) for the web app and a Node API (Fastify here, but Express works the same way) in front of PostgreSQL. I previously published a pre-launch security checklist; this post is the implementation side of the tenant and RBAC items on that list.
1. The model: organizations, branches, memberships, assignments
The domain looks like this:
- An organization (tenant) is a business: one brand, possibly many branches.
- A branch belongs to one organization. Warehouses belong to a branch.
- A membership links a user to an organization.
- A role is a named bundle of permissions, defined per organization.
- An assignment gives a membership a role, either for the whole organization or for one branch.
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL
);
CREATE TABLE branches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
name text NOT NULL
);
CREATE TABLE memberships (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
user_id uuid NOT NULL,
is_owner boolean NOT NULL DEFAULT false,
UNIQUE (org_id, user_id)
);
CREATE TABLE roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
name text NOT NULL,
permissions text[] NOT NULL -- e.g. {'sales.create','inventory.transfer.approve'}
);
CREATE TABLE role_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
membership_id uuid NOT NULL REFERENCES memberships(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES roles(id),
org_id uuid NOT NULL REFERENCES organizations(id),
branch_id uuid REFERENCES branches(id), -- NULL = all branches in the org
UNIQUE NULLS NOT DISTINCT (membership_id, role_id, branch_id) -- PostgreSQL 15+
);
Two design notes. First, every tenant-owned table carries org_id, even when it could be derived through a join. Redundant? Slightly. But it makes row-level security policies simple and fast, and it makes "which tenant owns this row?" a question you never have to reason about. Second, branch_id IS NULL meaning "organization-wide" keeps the model small; a shift supervisor gets a role assignment for one branch, an owner or regional manager gets one with NULL. Because branch_id can be NULL, the uniqueness rule uses NULLS NOT DISTINCT so the same organization-wide assignment can't be inserted twice.
2. Request flow: who decides what
Browser ──► Next.js middleware (cheap: is there a session cookie? redirect if not)
──► Next.js server code (Server Components / Actions / Route Handlers)
──► Node API (authenticate, load membership + grants, authorize)
──► PostgreSQL (RLS: rows outside app.org_id are invisible)
Each layer has one job. The mistake I see most is putting the authorization decision in the first layer because it's convenient, and then assuming everything behind it is safe.
3. Next.js middleware: a gate, not a guard
Next.js middleware runs before routing and is great for redirects. It's a bad place for authorization decisions that depend on your database, and you shouldn't treat a middleware check as the only thing protecting a route.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const hasSession = req.cookies.has('sid');
if (!hasSession && !req.nextUrl.pathname.startsWith('/login')) {
const url = req.nextUrl.clone();
url.pathname = '/login';
url.searchParams.set('next', req.nextUrl.pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/health).*)'],
};
That's all middleware does here: no session, go to login. It doesn't decide which organization you belong to or what you may do.
Choosing the active organization. Users with several memberships need a way to switch. A path segment like /o/[orgSlug]/... is fine for navigation, but treat it as a request to use a membership, not as proof of one. The Node API checks it.
4. Server Actions and Route Handlers are public endpoints
A Server Action looks like a function call, but it is an HTTP endpoint anyone can invoke with crafted input. The same goes for Route Handlers. So every one of them authorizes, and none trusts IDs from the client.
// app/o/[orgSlug]/inventory/actions.ts
'use server';
import { api } from '@/lib/api'; // server-only fetch wrapper that forwards the session cookie
export async function approveTransfer(transferId: string) {
// Don't pass orgId or branchId from the client. The API derives them.
return api.post(`/transfers/${encodeURIComponent(transferId)}/approve`);
}
Keeping business logic and authorization in the Node API means there is exactly one place where the rules live, whether a request comes from the Next.js app, a mobile client or an integration key.
5. The Node API: build a request context once
On every request, authenticate the session, resolve the requested organization, verify the membership, and load the user's grants. Put the result on the request and pass it explicitly to services.
// api/plugins/context.ts
import fp from 'fastify-plugin';
export type Grant = { permission: string; branchId: string | null };
export type Ctx = {
userId: string;
orgId: string;
membershipId: string;
isOwner: boolean;
grants: Grant[];
};
declare module 'fastify' {
interface FastifyRequest { ctx: Ctx }
}
export default fp(async (app) => {
app.addHook('preHandler', async (req, reply) => {
const session = await app.sessions.verify(req.cookies.sid); // your session store
if (!session) return reply.code(401).send({ error: 'unauthenticated' });
const requestedOrg = req.headers['x-org-slug']; // a request, not a claim
const m = await app.db.oneOrNone(
`SELECT m.id, m.org_id, m.is_owner
FROM memberships m JOIN organizations o ON o.id = m.org_id
WHERE m.user_id = $1 AND o.slug = $2`,
[session.userId, requestedOrg],
);
// Same response for "no such org" and "not your org": don't leak existence.
if (!m) return reply.code(404).send({ error: 'not_found' });
const grants = await app.db.any<Grant>(
`SELECT unnest(r.permissions) AS permission, ra.branch_id AS "branchId"
FROM role_assignments ra JOIN roles r ON r.id = ra.role_id
WHERE ra.membership_id = $1`,
[m.id],
);
req.ctx = { userId: session.userId, orgId: m.org_id,
membershipId: m.id, isOwner: m.is_owner, grants };
});
});
You can use AsyncLocalStorage to make the context implicit, and it works well. I still prefer passing ctx explicitly into services: it shows up in function signatures, in code review and in tests, and there's no way for a background job to accidentally run "inside" a stale request context.
6. Authorization: can(ctx, permission, scope)
The authorization function is small on purpose.
// api/authz.ts
import type { Ctx } from './plugins/context';
export class Forbidden extends Error { status = 403; }
export function can(ctx: Ctx, permission: string, scope?: { branchId?: string }) {
if (ctx.isOwner) return true;
return ctx.grants.some(
(g) =>
g.permission === permission &&
(g.branchId === null || (scope?.branchId !== undefined && g.branchId === scope.branchId)),
);
}
export function assertCan(ctx: Ctx, permission: string, scope?: { branchId?: string }) {
if (!can(ctx, permission, scope)) throw new Forbidden(permission);
}
Note the asymmetry: a branch-scoped grant only matches when the caller names a branch and it's the right one. An action without a branch scope requires an organization-wide grant. That prevents a supervisor in branch 1 from calling an "organization-level" endpoint just because they hold the permission somewhere.
Load the target, then authorize against its scope
Never authorize against a branch ID sent by the client. Load the resource (inside the tenant transaction, see below) and use its branch.
// api/routes/transfers.ts
app.post('/transfers/:id/approve', async (req) => {
return withTenant(req.ctx, async (tx) => {
const t = await tx.oneOrNone(
`SELECT id, from_branch_id, to_branch_id, status FROM transfers WHERE id = $1`,
[req.params.id],
);
if (!t) throw new NotFound(); // RLS already hid other tenants' rows
// A transfer touches two places: check both ends.
assertCan(req.ctx, 'inventory.transfer.approve', { branchId: t.from_branch_id });
assertCan(req.ctx, 'inventory.transfer.receive_into', { branchId: t.to_branch_id });
const updated = await tx.result(
`UPDATE transfers SET status = 'approved', approved_by = $2
WHERE id = $1 AND status = 'requested'`, // guarded transition
[t.id, req.ctx.userId],
);
if (updated.rowCount === 0) throw new Conflict('already_processed');
await audit(tx, req.ctx, 'transfer.approve', t.id, { status: t.status }, { status: 'approved' });
return { ok: true };
});
});
7. Tenant scoping in the database with RLS
Application checks are necessary; they're not sufficient. The database should refuse to return another tenant's rows even if a query forgets WHERE org_id = $1.
ALTER TABLE transfers ENABLE ROW LEVEL SECURITY;
ALTER TABLE transfers FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON transfers
USING (org_id = nullif(current_setting('app.org_id', true), '')::uuid)
WITH CHECK (org_id = nullif(current_setting('app.org_id', true), '')::uuid);
current_setting(..., true) returns NULL when the setting is missing, and org_id = NULL is never true, so a query that runs without a tenant context returns no rows. That's the fail-closed behaviour you want.
Set the value per transaction, never per connection:
// api/db/withTenant.ts
export async function withTenant<T>(ctx: Ctx, fn: (tx: Tx) => Promise<T>): Promise<T> {
return db.tx(async (tx) => {
// third argument `true` = local to this transaction
await tx.none(`SELECT set_config('app.org_id', $1, true)`, [ctx.orgId]);
return fn(tx);
});
}
With a connection pool, a session-level SET leaks into whichever request gets that connection next. set_config(..., true) (equivalent to SET LOCAL) disappears at commit or rollback.
Two more requirements that are easy to miss:
- The application's database role must not own the tables and must not be a superuser or have
BYPASSRLS; owners bypass policies unlessFORCE ROW LEVEL SECURITYis set, and superusers always bypass them. - Migrations and admin tooling use a different role from the application.
You can push branch scope into RLS as well, but I usually keep the database responsible for the tenant boundary and the application responsible for the branch and permission rules. Tenant leaks are catastrophic and simple to express in SQL; branch rules are richer and easier to test in code.
8. Field-level redaction in the response
Some data depends on permission, not just on scope: purchase costs, margins, wages. Remove it when you build the response, not in the React component.
// api/serializers/dailySummary.ts
export function serializeDailySummary(ctx: Ctx, s: DailySummary, branchId: string) {
const base = { branchId, openOrders: s.openOrders, alerts: s.alerts };
if (!can(ctx, 'finance.view', { branchId })) return base; // no keys, not nulls
return { ...base, netSales: s.netSales, grossMargin: s.grossMargin };
}
Omit the keys entirely rather than sending null; a null still tells the client the field exists and invites someone to wonder why.
9. Role management rules: no self-escalation, keep an owner
Role editing is where authorization systems usually break, because the endpoints that change permissions are themselves permissioned.
export async function assignRole(ctx: Ctx, tx: Tx, input: { membershipId: string; roleId: string; branchId: string | null }) {
assertCan(ctx, 'members.manage', input.branchId ? { branchId: input.branchId } : undefined);
if (input.membershipId === ctx.membershipId && !ctx.isOwner) {
throw new Forbidden('cannot_change_own_roles');
}
const role = await tx.one(`SELECT permissions FROM roles WHERE id = $1`, [input.roleId]);
// You can only hand out permissions you hold yourself, in that scope.
for (const p of role.permissions) {
assertCan(ctx, p, input.branchId ? { branchId: input.branchId } : undefined);
}
// ...insert assignment + audit event
}
export async function removeMember(ctx: Ctx, tx: Tx, membershipId: string) {
assertCan(ctx, 'members.manage');
// Lock the org's owner rows first so two concurrent removals can't both pass.
const owners = await tx.any(
`SELECT id FROM memberships WHERE org_id = $1 AND is_owner FOR UPDATE`, [ctx.orgId],
);
const isTargetOwner = owners.some((o) => o.id === membershipId);
if (isTargetOwner && owners.length === 1) throw new Conflict('last_owner');
// ...delete + audit event
}
Locking the owner rows before counting matters: without it, two owners removing each other at the same moment could both succeed. The same invariant should also be enforced when someone demotes an owner, not only when they remove one.
10. Tests: write the attacker's tests first
These are the tests I write before the feature tests, using Vitest and supertest against a real PostgreSQL (in a container) rather than mocks. RLS can't be tested with a mocked database.
// test/authz.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { seed, loginAs, http } from './helpers';
describe('tenant and branch isolation', () => {
let s: Awaited<ReturnType<typeof seed>>;
beforeAll(async () => { s = await seed(); }); // orgA (branch1, branch2), orgB
it('cannot read another tenant\'s transfer by id', async () => {
const a = await loginAs(s.orgA.owner, 'org-a');
const res = await http(a).get(`/transfers/${s.orgB.transferId}`);
expect(res.status).toBe(404); // not 403: don't confirm it exists
});
it('branch-1 supervisor cannot approve a transfer out of branch 2', async () => {
const sup = await loginAs(s.orgA.branch1Supervisor, 'org-a');
const res = await http(sup).post(`/transfers/${s.orgA.transferFromBranch2}/approve`);
expect(res.status).toBe(403);
});
it('cannot use an org slug you are not a member of', async () => {
const a = await loginAs(s.orgA.owner, 'org-b');
const res = await http(a).get('/branches');
expect(res.status).toBe(404);
});
it('cannot grant yourself a role', async () => {
const mgr = await loginAs(s.orgA.branch1Manager, 'org-a');
const res = await http(mgr).post('/members/assign').send({
membershipId: s.orgA.branch1Manager.membershipId, roleId: s.orgA.roles.owner, branchId: null,
});
expect(res.status).toBe(403);
});
it('cannot remove the last owner', async () => {
const owner = await loginAs(s.orgA.owner, 'org-a');
const res = await http(owner).delete(`/members/${s.orgA.owner.membershipId}`);
expect(res.status).toBe(409);
});
it('omits financial fields for users without finance.view', async () => {
const cashier = await loginAs(s.orgA.branch1Cashier, 'org-a');
const res = await http(cashier).get(`/branches/${s.orgA.branch1}/daily-summary`);
expect(res.body).not.toHaveProperty('netSales');
expect(res.body).not.toHaveProperty('grossMargin');
});
it('a query without tenant context returns nothing', async () => {
const rows = await s.appRoleDb.any('SELECT id FROM transfers'); // no set_config
expect(rows).toHaveLength(0);
});
});
The last test is my favourite: it proves that the database itself fails closed when application code forgets to set a tenant.
11. Pitfalls I check for in every multi-tenant Next.js app
-
Caching per-tenant data under a shared key. Anything cached across requests (
unstable_cache, a Redis layer, a CDN) must include the organization (and, for permission-dependent responses, the user or role) in the key, or simply not be cached. React's per-requestcache()is fine; cross-request caches are where leaks happen. - Static rendering of authenticated pages. Make sure pages that read the session are dynamic. A statically generated "dashboard" built with someone's data is a leak served to everyone.
-
Trusting IDs from forms. Hidden inputs with
orgIdorbranchIdare user input. Derive them on the server. -
Background jobs without a tenant. Workers, cron jobs and exports must run inside
withTenantfor one organization at a time. A job that loops over "all orders" with an RLS-bypassing role is a leak waiting for a bug. - Search and analytics indexes. If you copy data into a search engine or warehouse, the tenant filter must exist there too, applied server-side.
- Files and signed URLs. Storage paths should include the tenant, and URL signing should check the requester's scope at signing time.
- Error messages that confirm existence. Return the same 404 for "doesn't exist" and "not yours".
- Integration keys with organization-wide power. API keys should be scoped (for example to a single branch), stored hashed, and shown only once.
-
Role names in code.
if (user.role === 'manager')scattered through the code base will drift. Check permissions, not role names. - No audit on permission changes. Every assignment, removal and ownership change should write an audit event with actor, time and before/after state.
Wrapping up
The architecture is deliberately boring: middleware redirects, the API decides, the database refuses. Each layer assumes the one in front of it might be wrong. That's what makes it safe to move fast later, when a new endpoint, a new job or a new client inevitably forgets something.
About the author
Mohammadreza Alamnejad is a programmer and network security professional, a UI/UX designer and developer, and the founder of Arka Studio, a design and development studio in Tehran. He built NABZA, a multi-tenant café and restaurant operations SaaS, along with Avenya, Natureio and Radepa. He studied Computer and IT at Sharif University of Technology and Iran University of Science and Technology. More on his founder page at Arka Studio.
Top comments (0)