# 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.
| Criteria | RBAC fits better | ABAC fits better | What we recommend in Next.js App Router |
|---|---|---|---|
| Team size and speed | Faster to ship and explain | More complex to model | Start with RBAC, add ABAC where needed |
| Multitenancy | Only for coarse tenant roles | Best for tenant isolation and ownership | ABAC plus database RLS for tenant boundaries |
| Feature gating by plan | Clunky via roles | Natural via attributes like plan | ABAC for plan checks, keep roles for admin access |
| Resource ownership | Awkward unless many roles | Natural via resource.ownerId | ABAC at resource layer |
| Auditing and compliance | Basic role logs | Rich context and conditions | Combine: log role plus attributes used |
| Policy drift risk | Lower if roles are stable | Higher without discipline | Centralize policies, enforce invariants in DB |
| Non-web clients | Needs backend enforcement | Needs backend enforcement | Do 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”.
# Recommended Layered Architecture#
A secure authorization setup in Next.js App Router usually looks like this:
- 1Middleware: block obvious cases early (unauthenticated, missing tenant, blocked role)
- 2Server-side guards: enforce decisions in Server Components, route handlers, and server actions
- 3Database 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
| Entity | Example fields | Notes |
|---|---|---|
users | id, email | Auth provider owns identity |
user_roles | user_id, role | Role is an enum string |
role_permissions | role, permission | Optional 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
| Entity | Example fields | Notes |
|---|---|---|
tenants | id, plan | Plan drives feature gates |
tenant_memberships | tenant_id, user_id, role | Role is per tenant |
projects | id, tenant_id, owner_id | Core 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
tenantis 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.
// 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.
// 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.
// 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:
// 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”.
// 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:
// 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.
// 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:
requireUserrequireTenantRolerequirePermissionassertCanReadProjectassertCanUpdateInvoice
…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.
-- 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.
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.
| Role | Example permissions |
|---|---|
owner | tenant.manage, billing.write, members.write |
admin | tenant.manage, members.write, billing.read |
editor | projects.write, projects.read |
viewer | projects.read |
// 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.
// 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.
// 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.readdocument.exportdocument.updatedocument.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_admineu_editorenterprise_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.
Mistake 4: Forgetting “list endpoints” and search#
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.
| Layer | What to implement | Done when |
|---|---|---|
| Middleware | Redirect unauthenticated users, block obvious admin routes | Protected sections are not reachable via URL |
| Server Components | requireUser, requireRole, requireTenantRole | Unauthorized pages fail before rendering data |
| Route Handlers | Explicit require... checks on every mutation | API cannot be called without correct role or membership |
| Database | RLS policies for tenant isolation and ownership | Cross-tenant reads are impossible even with buggy queries |
| Logging | Log denied decisions with action and tenant | You can debug 403 issues quickly |
| Testing | Unit test can policies, add API tests for 403 and 404 | Policy 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
requireTenantRoleandcan(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
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →React Testing Strategy in 2026: Vitest + React Testing Library + MSW for Confident Releases
A pragmatic React testing strategy for 2026 using Vitest, React Testing Library, and MSW. Learn a realistic test pyramid, reduce flakiness, and ship confidently with CI-ready patterns.
Modern React Frontend Architecture: Feature-Based Modules, Boundaries, and Scalability
A practical guide to React frontend architecture feature-based modules: clear boundaries, shared layers, dependency rules, and a maintainable folder strategy for React and Next.js.
Observability for Next.js App Router in 2026: Sentry, OpenTelemetry, Traces, and Actionable Alerts
End-to-end setup for Next.js logging, monitoring, and tracing with Sentry and OpenTelemetry across Server Actions, Route Handlers, and Edge versus Node runtimes—plus dashboards, alert thresholds, and session-to-trace correlation.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js + Supabase RLS for Multi‑Tenant SaaS: Policies, Roles, and Safe Data Access
A practical guide to Next.js App Router and Supabase Row Level Security for multi-tenant SaaS: table design, policies, roles, server-side access patterns, common pitfalls, and a deployment checklist.
Next.js + Supabase SaaS Starter Architecture (App Router): Auth, RLS, Billing, and Multi-Tenancy
A production-ready blueprint for a Next.js App Router + Supabase SaaS starter architecture: auth, Postgres data model, RLS policies, Stripe billing, and multi-tenant organization design with concrete examples.
Next.js Multitenant SaaS Architecture: Tenancy Models, Routing, Auth, and Data Isolation (2026 Guide)
A practical guide to Next.js multitenant SaaS architecture: tenancy models, tenant-aware routing with App Router and middleware, auth patterns, and hardening data isolation to prevent leaks.