Authorization, Roles, and Tenant Isolation

Authorization, roles, and tenant isolation decide what an already identified user may do and which customer-owned records that user may see. In a Next.js application, every Server Component, Route Handler, Server Action, and database query that touches private data must derive access from the authenticated session, not from hidden form fields, client state, or a URL alone.

Within the course security section, the outcome is one enforceable server-side authorization model.

Purpose and Outcome

Authentication answers “who is this request from?” Authorization answers “what may this principal do?” Tenant isolation answers “which organization, workspace, account, or customer partition may this principal affect?” A user can be an administrator in one tenant and a viewer in another, so the application has to represent membership per tenant and repeat the check close to the sensitive operation.

A safe implementation produces four visible outcomes. Anonymous users get a sign-in redirect or a 401 response. Authenticated users without tenant membership get a 404-style “not found” or tenant switch prompt. Members with the wrong role get 403. Members with the required role only read or change rows whose tenantId matches the active tenant in the server session.

Internal Mechanism in Next.js

In the App Router, private work normally happens in Server Components, Server Actions, and Route Handlers. These run on the server, so they can read cookies, validate sessions, call databases, and keep secrets out of the browser bundle. Client Components can hide or disable buttons for ergonomics, but they are never the authority. A user can still POST to an action endpoint or alter a request in developer tools.

The usual request path is: middleware performs a coarse route check, the page or handler loads the server session, an authorization helper resolves tenant membership, and the database query includes both the resource identifier and the tenant identifier. If a project URL is /app/projects/p_123, the query should ask for “project where id equals p_123 and tenantId equals the active tenant.” That turns tenant isolation into a data-access invariant.

Roles are commonly stored as membership rows rather than as one global user field. A simple schema is User, Tenant, Membership, and domain tables such as Project. Membership contains userId, tenantId, and role. Domain rows contain tenantId. This lets the same user have different permissions in different tenants and supports indexes such as (tenantId, id).

Syntax and API Anatomy

The authentication library can vary, but the shape is stable. A server-only auth() or getSession() function returns a session derived from signed cookies or headers. A role helper accepts the required role and active tenant. A data helper takes identifiers from the route or form, validates them, and queries with tenant scope. Pages can call redirect(); Route Handlers can return NextResponse.json(..., { status: 403 }); Server Actions can throw typed errors or return structured form errors.

Cache behavior is part of authorization. React cache() memoizes inside a render pass, but private tenant pages must not store one tenant’s result as a public artifact. Read the session inside server helpers, use dynamic rendering when needed, and invalidate with revalidatePath() after mutations.

Example 1: Role Decisions as a Pure Rule

Before wiring authorization into Next.js, make the rule explicit. This example models memberships and role rank without a framework. The deterministic output is 200, 403, and 404: the user can view Acme, cannot administer Acme, and has no Globex membership.

const roleRank = { viewer: 1, editor: 2, admin: 3 };

function canAccess(user, tenantId, minimumRole) {
  if (!user) return { ok: false, status: 401, reason: "sign in required" };
  const membership = user.memberships.find((m) => m.tenantId === tenantId);
  if (!membership) return { ok: false, status: 404, reason: "tenant not found" };
  if (roleRank[membership.role] < roleRank[minimumRole]) {
    return { ok: false, status: 403, reason: "insufficient role" };
  }
  return { ok: true, status: 200, role: membership.role };
}

const user = { memberships: [{ tenantId: "acme", role: "editor" }] };
console.log(canAccess(user, "acme", "viewer").status);
console.log(canAccess(user, "acme", "admin").status);
console.log(canAccess(user, "globex", "viewer").status);

This pure rule is useful in tests because it separates product policy from transport. Notice the distinction between “wrong role” and “wrong tenant.” Many applications intentionally return 404 for cross-tenant resource access so a user cannot enumerate another tenant’s identifiers.

Example 2: Middleware as a Coarse Gate

Middleware is good for redirecting anonymous users away from private URL prefixes. It runs before the route module, so it can reduce wasted work and create a consistent sign-in flow. It should not be the only authorization layer, because it does not know the target database row or the role required by each operation.

import { NextResponse } from "next/server";
import { auth } from "@/auth";

const protectedPrefixes = ["/app", "/dashboard"];

export default async function middleware(request) {
  const session = await auth();
  const pathname = request.nextUrl.pathname;
  const needsSession = protectedPrefixes.some((prefix) => pathname.startsWith(prefix));

  if (!needsSession) return NextResponse.next();
  if (!session?.user) {
    const signIn = new URL("/sign-in", request.url);
    signIn.searchParams.set("next", pathname);
    return NextResponse.redirect(signIn);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/app/:path*", "/dashboard/:path*"],
};

Expected behavior: /marketing passes without a session, /app/projects redirects anonymous users to /sign-in?next=/app/projects, and signed-in users continue to the route. The page, action, or handler must still perform tenant and role checks before returning data.

Example 3: A Server Action Mutation

A Server Action is a common place to make authorization mistakes because the form is rendered by your application. Treat it as a public endpoint. The browser can send a different project id, omit fields, or replay an older request. This action checks authentication, validates the input, verifies the user is an editor or administrator in the active tenant, and updates only a row in that tenant.

"use server";

import { auth } from "@/auth";
import { db } from "@/db";
import { revalidatePath } from "next/cache";

export async function updateProjectName(projectId, formData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("AUTHENTICATION_REQUIRED");

  const name = String(formData.get("name") || "").trim();
  if (name.length < 3) throw new Error("PROJECT_NAME_TOO_SHORT");

  const membership = await db.membership.findFirst({
    where: { userId: session.user.id, tenantId: session.user.activeTenantId },
    select: { role: true },
  });
  if (!membership || !["admin", "editor"].includes(membership.role)) {
    throw new Error("FORBIDDEN");
  }

  const updated = await db.project.updateMany({
    where: { id: projectId, tenantId: session.user.activeTenantId },
    data: { name },
  });
  if (updated.count !== 1) throw new Error("PROJECT_NOT_FOUND");

  revalidatePath("/app/projects");
}

The important line is the mutation filter: { id: projectId, tenantId: session.user.activeTenantId }. If the project belongs to another tenant, updateMany changes zero rows and the action reports PROJECT_NOT_FOUND. That is safer than fetching by id, trusting the submitted tenant id, or relying on a disabled button.

Example 4: Tenant-Scoped Reads for Server Components

Server Components can fetch private data directly. Keep the helper server-only, read the session inside it, and scope the query by the active tenant. The page can render normally after the helper returns, but it never receives another tenant’s row.

import { cache } from "react";
import { auth } from "@/auth";
import { db } from "@/db";

export const getTenantProject = cache(async function getTenantProject(projectId) {
  const session = await auth();
  if (!session?.user?.id || !session.user.activeTenantId) {
    throw new Error("AUTHENTICATION_REQUIRED");
  }

  const project = await db.project.findFirst({
    where: { id: projectId, tenantId: session.user.activeTenantId },
    select: { id: true, name: true, tenantId: true },
  });
  if (!project) throw new Error("PROJECT_NOT_FOUND");
  return project;
});

Expected behavior: an authenticated member of the active tenant sees the project, an anonymous user gets AUTHENTICATION_REQUIRED or a page-level redirect, and a member of a different tenant receives the same not-found path as a missing project. The helper avoids accepting tenantId from route params, where it could be forged.

Design Choices and Trade-offs

Role-based access control is easy to explain and test, but it can become coarse. admin, editor, and viewer work for many SaaS products until features need resource-level ownership, approvals, or billing entitlements. Permission-based access control is more flexible, but the policy surface grows quickly. A pragmatic pattern is roles for broad tenant capability and explicit ownership checks for high-risk resources.

Subdomains such as acme.example.com make tenant context visible and reduce wrong-tenant navigation, but they complicate local development, cookies, and custom domains. Path-based tenancy such as /t/acme is simpler to route, but every link and form must preserve tenant context. Session-based active tenants produce clean URLs, but server checks must reject stale active-tenant values after membership changes.

Database-enforced isolation is stronger than application convention. Composite unique indexes, foreign keys including tenantId, and row-level security can prevent entire classes of mistakes. The trade-off is operational complexity: migrations, policy debugging, and local test setup need more care.

Failure Modes and Troubleshooting

Symptom: a user sees another tenant’s project after switching workspaces. Cause: the page or fetch result was cached without tenant-specific scoping. Diagnose: log the user id, active tenant id, route, cache mode, and selected row tenant id. Check for fetch(..., { cache: "force-cache" }) or module-level private data. Correct: read the session inside the server helper, include tenantId in the query, and disable or tag caches that cannot be safely shared.

Symptom: viewers can submit a hidden edit form successfully. Cause: the Client Component hid the submit button, but the Server Action did not re-check the role. Diagnose: call the action with a viewer session in an integration test or by replaying the network request. Correct: move the role check into the action before the mutation and test the 403 path.

Symptom: valid users get random 404s after membership changes. Cause: the session still contains a stale active tenant or role claim. Diagnose: compare the role in the session with the membership table and inspect session refresh timing. Correct: treat session roles as UI hints, verify current membership on sensitive operations, and force session refresh after membership changes.

Security, Performance, and Reliability

Authorization bugs are data exposure bugs, so logs should contain bounded identifiers and decision outcomes, not secret tokens or full records. Use structured categories such as AUTHENTICATION_REQUIRED, FORBIDDEN, and PROJECT_NOT_FOUND. Rate-limit sensitive mutation routes and audit membership changes, role changes, tenant switches, exports, and destructive actions.

Performance depends on making tenant scope cheap. Add indexes that match common filters, usually starting with tenantId plus the local identifier or sort field. Avoid loading all memberships or all tenant records into memory to check access. Reliability improves when every denial path is deliberate: redirects for pages, status codes for APIs, and stable action errors for forms.

Hands-on Lab

Prerequisites: a Next.js App Router project with server-side authentication, a database table for memberships, and one tenant-owned resource such as projects. Use a development database with two tenants, one editor in tenant A, and one viewer in tenant B.

  1. Create a server-only authorization helper that accepts requiredRole, reads the session, queries membership by userId and active tenantId, and returns the tenant id plus role or throws a typed error.
  2. Update one private page to call a tenant-scoped read helper. Verify the query includes both the resource id and the tenant id.
  3. Update one Server Action mutation to call the helper before writing. The mutation should use a tenant-scoped where clause and report not found when zero rows change.
  4. Add a viewer test that calls the action and expects forbidden. Add a cross-tenant test that requests tenant B’s resource while tenant A is active and expects not found.
  5. After a successful edit, call revalidatePath() or invalidate the relevant tagged data so the tenant page shows fresh data.

Verification: sign in as the editor and confirm editing tenant A succeeds. Switch to viewer access and confirm the UI hides the edit control and the direct action call still fails. Attempt to load tenant B’s project id while tenant A is active and confirm no tenant B fields are returned. Inspect database rows to verify only the intended tenant changed.

Cleanup: delete lab tenants and projects, revoke temporary memberships, and remove broad development roles granted for the exercise. If you changed cache settings globally, restore the previous defaults and rerun the cross-tenant test.

Assessment Exercises

  1. A page calls getProject(params.id) and then checks project.tenantId against the session. Rewrite the access pattern so the database query enforces tenant scope from the start.
  2. Design a role matrix for projects where viewers can comment, editors can rename, and administrators can invite members. Which checks belong in Client Components, Server Components, Server Actions, and the database?
  3. Explain why middleware can redirect anonymous users but cannot prove that a signed-in user may update a specific project.
  4. A cached dashboard leaks one tenant’s project count to another tenant after workspace switching. List the first three diagnostics you would run and the correction you would make.
  5. Choose between global roles, tenant membership roles, and per-resource permissions for a consulting platform. Defend the choice and name one migration risk.

Summary

Next.js authorization is strongest when every private operation follows the same chain: session, membership, role, tenant-scoped query, and deliberate cache behavior. Middleware improves navigation but does not replace server-side checks. Client UI improves usability but does not authorize anything. Model roles as tenant memberships, keep tenant ids out of client authority, test refusal paths, and make the database participate in isolation wherever possible.