Web Development
Next.jsAuthorizationSecurityRBACABACSupabasePostgreSQLApp Router

Next.js Authorization in the App Router: RBAC vs ABAC with Middleware, RLS, and Policy Patterns

AO
Adrijan Omićević
·15 min read

# What This Guide Covers#

Authorization is deciding what an authenticated user can do — which pages they can open, which API routes they can call, and which rows they can read or update.

This guide focuses on practical authorization for Next.js App Router apps, using the target keyword Next.js authorization RBAC ABAC, and shows how to combine three layers:

  • Middleware checks for early redirects and coarse route gates
  • Server component and route handler guards for real enforcement in your Next.js backend
  • Database-enforced policies like PostgreSQL Row Level Security for non-bypassable guarantees

If you’re still setting up authentication, start here first: Next.js Authentication Guide with NextAuth, Clerk, and Supabase.

# Mental Model: RBAC vs ABAC in Next.js#

RBAC and ABAC are not competing frameworks as much as they are different dimensions of policy.

  • RBAC (Role Based Access Control): decisions based on roles like admin, member, viewer.
  • ABAC (Attribute Based Access Control): decisions based on attributes of the user, resource, and context like tenantId, ownerId, plan, region, isSensitive, ipRiskScore.

In a real App Router app, you’ll typically use:

  • RBAC for navigation and section-level access (admin area, billing area).
  • ABAC for resource-level access (only rows from your tenant, only your own documents, only within an allowed region, only if plan allows).

Why this matters in App Router specifically#

App Router pushes you toward server-side rendering and Server Components, which is good for authorization because:

  • checks can run server-side without leaking secrets
  • you can block data at the source before it hits the client
  • you can reduce “flash of unauthorized content” by deciding before rendering

But it also increases the number of entry points that need consistent policy:

  • server components fetching data
  • route handlers in app/api
  • server actions
  • middleware route matching
  • database queries

If your policies are inconsistent, users will find the weakest link.

# Decision Matrix: When to Prefer RBAC vs ABAC#

Use this as a starting decision matrix for Next.js authorization RBAC ABAC.

CriteriaRBAC fits betterABAC fits betterWhat we recommend in Next.js App Router
Team size and speedFaster to ship and explainMore complex to modelStart with RBAC, add ABAC where needed
MultitenancyOnly for coarse tenant rolesBest for tenant isolation and ownershipABAC plus database RLS for tenant boundaries
Feature gating by planClunky via rolesNatural via attributes like planABAC for plan checks, keep roles for admin access
Resource ownershipAwkward unless many rolesNatural via resource.ownerIdABAC at resource layer
Auditing and complianceBasic role logsRich context and conditionsCombine: log role plus attributes used
Policy drift riskLower if roles are stableHigher without disciplineCentralize policies, enforce invariants in DB
Non-web clientsNeeds backend enforcementNeeds backend enforcementDo not rely on middleware alone

🎯 Key Takeaway: Use RBAC to answer “can this user enter this area”, and ABAC to answer “can this user access this specific record right now”.

A secure authorization setup in Next.js App Router usually looks like this:

  1. 1
    Middleware: block obvious cases early (unauthenticated, missing tenant, blocked role)
  2. 2
    Server-side guards: enforce decisions in Server Components, route handlers, and server actions
  3. 3
    Database policies: enforce non-bypassable rules (tenant isolation, ownership) using RLS or equivalent

This matches how attackers probe apps: they bypass UI first, then try APIs, then exploit data access.

For a broader set of security controls beyond authorization, use our checklist: Web Application Security Checklist.

# Step 1: Model Your Authorization Data#

Before code, define your minimum viable policy model. Here are two common starting points.

Option A: RBAC-first schema (simple apps)#

  • users have one or multiple roles
  • roles map to permissions
EntityExample fieldsNotes
usersid, emailAuth provider owns identity
user_rolesuser_id, roleRole is an enum string
role_permissionsrole, permissionOptional if you want granular permissions

Good for: internal dashboards, single-tenant apps, few resources.

Option B: Multitenant ABAC-first schema (SaaS)#

  • every resource includes tenant_id
  • membership defines user attributes inside a tenant
  • ABAC checks attributes like tenantRole, plan, ownerId
EntityExample fieldsNotes
tenantsid, planPlan drives feature gates
tenant_membershipstenant_id, user_id, roleRole is per tenant
projectsid, tenant_id, owner_idCore ABAC attributes

Good for: SaaS, agencies, any multi-organization product.

If you’re using Supabase and need tenant isolation patterns, read: Next.js Supabase Row Level Security for Multitenant SaaS.

ℹ️ Note: Most authorization incidents in SaaS are cross-tenant data leaks. If you are multitenant, treat tenant isolation as a database invariant, not a frontend rule.

# Step 2: Middleware Authorization Gates (Coarse, Fast, Not Final)#

Middleware is great for:

  • redirecting unauthenticated users away from protected routes
  • blocking non-admin users from obvious admin sections
  • ensuring a tenant is selected before entering tenant routes

Middleware is not great for:

  • record-level authorization
  • anything that depends on database reads
  • being your only protection layer

Middleware pattern: route-level RBAC gate#

Below is a minimal pattern that expects your auth layer to provide claims like userId and roles. The exact session retrieval depends on your auth provider, so keep this as a template.

TypeScript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
const adminOnly = [/^\/admin(\/|$)/];
const protectedRoutes = [/^\/app(\/|$)/, /^\/admin(\/|$)/];
 
function matches(pathname: string, rules: RegExp[]) {
  return rules.some((r) => r.test(pathname));
}
 
export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
 
  if (!matches(pathname, protectedRoutes)) return NextResponse.next();
 
  // PSEUDO: replace with NextAuth/Clerk/Supabase session lookup
  const session = await getSessionFromRequest(req);
 
  if (!session?.userId) {
    const url = req.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("next", pathname);
    return NextResponse.redirect(url);
  }
 
  if (matches(pathname, adminOnly) && !session.roles?.includes("admin")) {
    return NextResponse.redirect(new URL("/403", req.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/app/:path*", "/admin/:path*"],
};

Middleware ABAC gate: require tenant context#

A common SaaS pattern is /t/[tenantSlug]/... routes. Middleware can enforce that tenantSlug exists and that the user has some membership, but avoid heavy DB calls.

TypeScript
// middleware.ts (tenant presence check)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
 
  if (!pathname.startsWith("/t/")) return NextResponse.next();
 
  const parts = pathname.split("/").filter(Boolean);
  const tenantSlug = parts[1];
 
  if (!tenantSlug) return NextResponse.redirect(new URL("/select-tenant", req.url));
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/t/:path*"],
};

⚠️ Warning: Do not store “authorization truth” in cookies or middleware-only claims unless they are cryptographically signed and short-lived. If you can’t confidently validate them, treat them as hints for UX only.

# Step 3: Server Component Guards (Real Enforcement for Pages)#

Server Components are where App Router shines for authorization. You can fetch the session and enforce policies before rendering any sensitive data.

Pattern: requireUser and requireRole#

Keep these functions server-only and reuse them across pages, layouts, server actions, and route handlers.

TypeScript
// lib/authz.ts
export type Session = {
  userId: string;
  roles: string[];
  tenantId?: string;
};
 
export async function requireUser(): Promise<Session> {
  const session = await getSessionOnServer(); // provider-specific
 
  if (!session?.userId) {
    throw new Error("UNAUTHENTICATED");
  }
  return session;
}
 
export function requireRole(session: Session, role: string) {
  if (!session.roles?.includes(role)) {
    throw new Error("FORBIDDEN");
  }
}

Then use it in a Server Component page:

TypeScript
// app/admin/page.tsx
import { requireUser, requireRole } from "@/lib/authz";
 
export default async function AdminPage() {
  const session = await requireUser();
  requireRole(session, "admin");
 
  return <div>Admin dashboard</div>;
}

Pattern: ABAC guard for tenant membership#

For tenant routes, you often need “user is a member of tenant X” and maybe “role in this tenant is at least editor”.

TypeScript
// lib/policies/tenant.ts
export type TenantRole = "owner" | "admin" | "editor" | "viewer";
 
const rank: Record<TenantRole, number> = {
  owner: 4,
  admin: 3,
  editor: 2,
  viewer: 1,
};
 
export function requireTenantRole(input: {
  userRole: TenantRole | null;
  minRole: TenantRole;
}) {
  if (!input.userRole) throw new Error("FORBIDDEN");
  if (rank[input.userRole] < rank[input.minRole]) throw new Error("FORBIDDEN");
}

Use it inside a tenant page after fetching membership:

TypeScript
// app/t/[tenantSlug]/settings/page.tsx
import { requireUser } from "@/lib/authz";
import { requireTenantRole } from "@/lib/policies/tenant";
 
export default async function TenantSettingsPage(props: {
  params: Promise<{ tenantSlug: string }>;
}) {
  const session = await requireUser();
  const { tenantSlug } = await props.params;
 
  const membership = await getMembership({
    userId: session.userId,
    tenantSlug,
  });
 
  requireTenantRole({ userRole: membership?.role ?? null, minRole: "admin" });
 
  return <div>Tenant settings</div>;
}

💡 Tip: Prefer throwing an error in server guards and mapping it to a 403 page in one place, instead of sprinkling redirects everywhere. It keeps your policy code pure and testable.

# Step 4: Route Handler and Server Action Enforcement (APIs Get Attacked First)#

Even if you block pages, attackers will call your route handlers directly. Every mutation needs an explicit authorization check.

Pattern: protect a route handler with RBAC plus ABAC#

Example: only tenant admins can invite users, and only for their own tenant.

TypeScript
// app/api/tenants/[tenantId]/invites/route.ts
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/authz";
import { requireTenantRole } from "@/lib/policies/tenant";
 
export async function POST(
  req: Request,
  context: { params: Promise<{ tenantId: string }> }
) {
  const session = await requireUser();
  const { tenantId } = await context.params;
 
  const membership = await getMembershipByTenantId({
    userId: session.userId,
    tenantId,
  });
 
  requireTenantRole({ userRole: membership?.role ?? null, minRole: "admin" });
 
  const body = await req.json();
  const email = String(body.email || "");
 
  const invite = await createInvite({ tenantId, email, invitedBy: session.userId });
 
  return NextResponse.json({ invite }, { status: 201 });
}

Pattern: deny by default#

A simple but effective policy stance: if your handler does not call a require... function, it is a bug.

Use naming conventions like:

  • requireUser
  • requireTenantRole
  • requirePermission
  • assertCanReadProject
  • assertCanUpdateInvoice

…and avoid ambiguous names like checkAccess.

# Step 5: Database-Enforced Authorization with RLS (The Non-Bypassable Layer)#

App-layer checks are necessary, but not sufficient if:

  • you have multiple backends or workers
  • you use direct database clients
  • you ever misconfigure an API route
  • you introduce a new query and forget a filter

Row Level Security in PostgreSQL is a proven way to make tenant isolation and ownership rules enforceable at the storage layer.

A common production setup for SaaS is:

  • App code passes the authenticated user id to the database session
  • RLS policies read that value and filter rows

If you’re on Supabase, the mechanics are well-documented and we cover multitenant patterns here: Next.js Supabase Row Level Security for Multitenant SaaS.

RLS policy pattern: tenant isolation#

This is conceptual SQL you can adapt. It assumes each row has a tenant_id and there is a tenant_memberships table.

SQL
-- Enable RLS
alter table projects enable row level security;
 
-- Allow select only if the user is a member of the row's tenant
create policy "projects_select_tenant_members"
on projects for select
using (
  exists (
    select 1 from tenant_memberships m
    where m.tenant_id = projects.tenant_id
      and m.user_id = auth.uid()
  )
);

RLS policy pattern: ownership for updates#

Example: any tenant member can read, but only the owner or admins can update.

SQL
create policy "projects_update_owner_or_admin"
on projects for update
using (
  exists (
    select 1 from tenant_memberships m
    where m.tenant_id = projects.tenant_id
      and m.user_id = auth.uid()
      and m.role in ('owner', 'admin')
  )
)
with check (
  projects.tenant_id in (
    select m.tenant_id from tenant_memberships m
    where m.user_id = auth.uid()
  )
);

Why RLS matters in numbers#

In incident retrospectives, “missing tenant filter” is one of the most common causes of SaaS data leaks. In a typical codebase with dozens of queries, one unscoped query is enough to expose all tenants.

RLS turns “remember to add where tenant_id = ... everywhere” into “the database refuses unsafe queries by default”.

🎯 Key Takeaway: If you are multitenant, enforce tenant isolation at the database layer. App checks improve UX and reduce load, but RLS prevents catastrophic cross-tenant leaks.

# Policy Patterns You Can Reuse (RBAC and ABAC)#

Below are common patterns we implement in Next.js projects to keep authorization consistent and maintainable.

Pattern 1: Permission strings on top of RBAC#

Instead of checking roles everywhere, map roles to permission strings like billing.read and billing.write. This reduces role sprawl.

RoleExample permissions
ownertenant.manage, billing.write, members.write
admintenant.manage, members.write, billing.read
editorprojects.write, projects.read
viewerprojects.read
TypeScript
// lib/policies/permissions.ts
const roleToPermissions: Record<string, string[]> = {
  owner: ["tenant.manage", "billing.write", "members.write", "projects.write", "projects.read"],
  admin: ["tenant.manage", "billing.read", "members.write", "projects.write", "projects.read"],
  editor: ["projects.write", "projects.read"],
  viewer: ["projects.read"],
};
 
export function hasPermission(role: string | null, permission: string) {
  if (!role) return false;
  return roleToPermissions[role]?.includes(permission) ?? false;
}

Pattern 2: ABAC with a single can function#

A single can(user, action, resource, context) function is easy to test and hard to misuse.

TypeScript
// lib/policies/can.ts
type Action = "project.read" | "project.update" | "invoice.read";
 
type User = {
  id: string;
  tenantRole: "owner" | "admin" | "editor" | "viewer";
  tenantId: string;
};
 
type Project = {
  id: string;
  tenantId: string;
  ownerId: string;
  isLocked: boolean;
};
 
export function can(user: User, action: Action, resource: Project) {
  if (user.tenantId !== resource.tenantId) return false;
 
  if (action === "project.read") return true;
 
  if (action === "project.update") {
    if (resource.isLocked && user.tenantRole !== "owner") return false;
    return user.tenantRole === "owner" || user.tenantRole === "admin" || resource.ownerId === user.id;
  }
 
  return false;
}

Pattern 3: Policy wrappers for data access#

Instead of fetching first and checking later, wrap the query so the policy is always applied.

TypeScript
// lib/data/projects.ts
import { can } from "@/lib/policies/can";
 
export async function getProjectOrThrow(input: {
  user: { id: string; tenantId: string; tenantRole: any };
  projectId: string;
}) {
  const project = await db.project.findUnique({ where: { id: input.projectId } });
  if (!project) throw new Error("NOT_FOUND");
 
  if (!can(input.user, "project.read", project)) throw new Error("FORBIDDEN");
  return project;
}

This prevents a frequent mistake: returning the record to the caller and trusting the caller to check access.

Pattern 4: Separate “visibility” vs “mutability”#

Many teams mix “can read” and “can update” in one rule. It leads to over-permissioning.

Use explicit actions:

  • document.read
  • document.export
  • document.update
  • document.delete

When you log authorization decisions, log the action string too. It makes audits and debugging practical.

# RBAC vs ABAC: Common Mistakes and How to Avoid Them#

Mistake 1: Using RBAC roles for everything#

If you keep adding roles to represent context, RBAC becomes unmaintainable. Examples:

  • tenant_123_admin
  • eu_editor
  • enterprise_billing_viewer

This explodes combinatorially and will slow down delivery.

Fix: keep roles stable, move context to ABAC attributes like tenantId, region, plan.

Mistake 2: Trusting the client for authorization#

Hiding UI elements is not authorization. Attackers call APIs directly.

Fix: enforce authorization in server code and the database. Treat UI checks as convenience only.

Mistake 3: Putting heavy authorization logic into middleware#

Middleware is great for route matching but not for DB-heavy policy evaluation. It increases latency on every request and is harder to debug.

Fix: keep middleware shallow, move resource checks to server components and route handlers, and enforce invariants with RLS.

Even if you protect a detail page, list endpoints often leak data. A single “search all projects” query without tenant scoping is enough.

Fix: apply tenant scoping in DB (RLS) and also in app queries for defense in depth.

# Practical Implementation Checklist (App Router)#

Use this as a minimal rollout plan.

LayerWhat to implementDone when
MiddlewareRedirect unauthenticated users, block obvious admin routesProtected sections are not reachable via URL
Server ComponentsrequireUser, requireRole, requireTenantRoleUnauthorized pages fail before rendering data
Route HandlersExplicit require... checks on every mutationAPI cannot be called without correct role or membership
DatabaseRLS policies for tenant isolation and ownershipCross-tenant reads are impossible even with buggy queries
LoggingLog denied decisions with action and tenantYou can debug 403 issues quickly
TestingUnit test can policies, add API tests for 403 and 404Policy changes do not introduce regressions

# Key Takeaways#

  • Use RBAC for coarse access like admin areas, and ABAC for resource-level rules like tenant boundaries, ownership, and plan limits.
  • Treat middleware as an early gate, not the source of truth. Enforce real authorization in server components, route handlers, and server actions.
  • For multitenant SaaS, make tenant isolation a database invariant using RLS, not a “remember to filter” convention.
  • Centralize rules into reusable policy functions like requireTenantRole and can(user, action, resource) to prevent drift across pages and APIs.
  • Prefer deny by default patterns: every sensitive handler must call a require... guard, and every table must have an RLS story.

# Conclusion#

Next.js App Router gives you the tools to do authorization correctly: middleware for early routing decisions, server-side guards for enforcement, and database policies for guarantees that can’t be bypassed.

If you want help designing a policy model, implementing RLS safely, or refactoring a messy RBAC setup into maintainable RBAC plus ABAC, Samioda can review your current codebase and ship a hardened authorization layer quickly. Start by aligning on your auth stack and claims, then lock tenant boundaries at the database level.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.