Fourteen SystemsFourteen Systems
← Back to blog
/
nextjssecuritymulti-tenant

Preventing IDOR in Next.js Multi-Tenant Applications

If you're building a multi-tenant SaaS app in Next.js, you're already dealing with the hardest problem: tenant isolation.

Most teams focus on authentication. Far fewer think carefully about IDOR.

What is IDOR?

IDOR (Insecure Direct Object Reference) happens when a user can access or modify a resource they don't own simply by changing an ID.

For example:

await db.project.update({
  where: { id: params.projectId },
  data: { name: input.name },
});

If projectId comes from the URL, and you don't scope it to the active organization, a user can potentially modify another tenant's project just by guessing an ID.

Authentication doesn't prevent this. Being logged in doesn't mean you're authorized to touch that specific record.

The common mistake

Most implementations rely on a check like:

if (session.userId !== project.ownerId) {
  throw new Error("Unauthorized");
}

This works in simple single-tenant apps. It breaks down quickly in:

  • Organization-based SaaS
  • Team accounts
  • Role-based systems
  • Shared resources

It also scatters permission logic throughout the codebase.

The safer pattern: structural tenant scoping

Instead of checking ownership after fetching the resource, scope the query itself.

In App Router, that looks like this:

const session = await requireOrg();

const project = await db.project.findFirst({
  where: {
    id: params.projectId,
    orgId: session.activeOrgId,
  },
});

If the project doesn't belong to the active organization, it simply doesn't exist. No secondary checks. No conditional branching. No partial access.

The boundary is enforced at the database query.

Require an active organization

The first guard should always ensure tenant context exists:

export async function requireOrg() {
  const session = await requireAuth();

  if (!session.activeOrgId) {
    throw new Error("Session has no activeOrgId.");
  }

  return session;
}

No org context, no data access. Silent fallthrough is what creates edge cases.

Enforce permissions before mutation

Tenant scoping prevents cross-org access. It does not enforce role permissions.

Those should be handled separately:

requirePermission(session.role, "project.update");

If this throws, the write never executes.

That separation matters:

  • Tenant isolation is structural. It defines what data exists in a query's scope.
  • Authorization is behavioral. It defines what actions a user can perform.

Conflating the two makes audits harder.

Why UI guards are not enough

Hiding a button in React does nothing for security. Even disabling a route client-side does nothing.

Every mutation should enforce:

  1. Authenticated user
  2. Active tenant
  3. Permission check
  4. Scoped database query

In that order.

The mental model

A safe mutation flow in a multi-tenant Next.js app should look like this:

// 1. Identity
await requireAuth();

// 2. Tenant boundary
const session = await requireOrg();

// 3. Authorization
requirePermission(session.role, "project.update");

// 4. Scoped query — IDOR structurally impossible
const project = await db.project.update({
  where: { id: projectId, orgId: session.activeOrgId },
  data: { name: input.name },
});

// 5. Audit trail
await audit({ actorId: session.user.id, orgId: session.activeOrgId,
  action: "resource.update", targetType: "project", targetId: project.id });

If any step fails, execution stops. There should never be a mutation that skips tenant scoping, trusts client input for orgId, or performs permission checks after fetching unrelated data.

Why this matters more than you think

IDOR issues rarely show up in happy-path testing. They show up when teams scale, roles change, organizations merge, IDs become predictable, and admin panels grow.

The larger the surface area, the easier it is to forget one unscoped query. That's why structural enforcement is better than "remembering to check."

Final thoughts

  • Authentication proves identity.
  • Tenant scoping enforces isolation.
  • RBAC defines capability.

You need all three.

If you're building multi-tenant SaaS on Next.js, make tenant scoping a structural rule — not a convention. It's much easier to enforce early than to audit later.

Every pattern described here — requireOrg(), requirePermission(), typed audit logging — is production code inside SaaSCoreX.

See the full architecture

Every system discussed here is production code inside SaaSCoreX.