Skip to content

Authorization Fundamentals and Why Permissions Break

Authentication vs authorization, common permission pitfalls, the fail-closed principle, and the goals every permission system should meet.

Abstract

Broken access control is the number one vulnerability in the OWASP Top 10 (2021, confirmed in the 2025 update). Most permission bugs don't come from a single missing check -- they come from an architecture that makes it too easy to forget checks and too hard to keep them consistent. This post examines why permission systems break, introduces the fail-closed principle, and establishes the goals that a well-designed permission system should meet.

Authentication vs Authorization

These two concepts are often bundled under the single word "auth," which leads to confusion. They are fundamentally different concerns.

Authentication answers: "Who are you?" It verifies identity -- valid session tokens, JWTs, OAuth flows. It is binary. Either the user has a valid session or they don't.

Authorization answers: "What can you do?" It evaluates permissions based on the user, the resource, and the action. It is contextual. A user might be allowed to edit their own documents but not someone else's, even though they are fully authenticated.

The confusion between these two creates a common failure pattern: codebases that check whether a user is logged in but never check whether that user is allowed to perform a specific action.

The HTTP status codes reflect this distinction. A 401 Unauthorized response means the server doesn't know who the user is (despite the confusing name, it means "unauthenticated"). A 403 Forbidden response means the server knows who the user is but they don't have permission.

Tip: If your application returns 401 for permission failures or 403 for login issues, that's a signal that authentication and authorization are being conflated in the codebase.

The Example Application

Throughout this series, we'll use a project and document management application as our running example. Here are the domain types:

typescript
interface User {  id: string;  email: string;  role: 'admin' | 'editor' | 'author' | 'viewer';}
interface Project {  id: string;  name: string;  ownerId: string;  visibility: 'public' | 'private';  members: ProjectMember[];}
interface ProjectMember {  userId: string;  role: 'editor' | 'author' | 'viewer';}
interface Document {  id: string;  projectId: string;  title: string;  content: string;  authorId: string;  status: 'draft' | 'review' | 'published';}

The intended permissions:

  • Admin: Full access to everything
  • Editor: Can edit and publish documents in projects they belong to
  • Author: Can create and edit their own documents, submit for review
  • Viewer: Can read published documents in projects they have access to

Simple enough in theory. In practice, this is where things break down.

Naive Permission Pitfalls

Scattered Permission Checks

The most common anti-pattern is permission logic duplicated across pages, server actions, and API routes -- each with slightly different logic.

Page component (app/projects/[id]/page.tsx):

typescript
// Permission check #1 -- in the page componentexport default async function ProjectPage({  params,}: {  params: Promise<{ id: string }>;}) {  const { id } = await params;  const session = await getSession();  const project = await db.project.findUnique({    where: { id },  });
  if (project.visibility === 'private') {    const isMember = project.members.some(      (m) => m.userId === session.userId    );    if (!isMember && session.user.role !== 'admin') {      redirect('/unauthorized');    }  }
  return <ProjectView project={project} />;}

Server action (app/actions/documents.ts):

typescript
// Permission check #2 -- similar but subtly different'use server';
export async function updateDocument(  documentId: string,  content: string) {  const session = await getSession();  const document = await db.document.findUnique({    where: { id: documentId },  });
  // Bug: forgot to check project membership!  // Only checks if user is the author  if (document.authorId !== session.userId) {    throw new Error('Unauthorized');  }
  // Bug: editors should be able to edit too,  // but this check excludes them  await db.document.update({    where: { id: documentId },    data: { content },  });}

API route (app/api/projects/[id]/documents/route.ts):

typescript
// Permission check #3 -- yet another variationexport async function GET(  req: Request,  { params }: { params: Promise<{ id: string }> }) {  const { id } = await params;  const session = await getSession();
  // Bug: checks admin OR member but doesn't check  // project visibility. Public project documents  // should be accessible to everyone.  if (session.user.role !== 'admin') {    const project = await db.project.findUnique({      where: { id },    });    const isMember = project.members.some(      (m) => m.userId === session.userId    );    if (!isMember) {      return new Response('Forbidden', { status: 403 });    }  }
  const documents = await db.document.findMany({    where: { projectId: id },  });
  // Bug: returns ALL documents including drafts to viewers  return Response.json(documents);}

Three locations. Three slightly different permission implementations. Each has a different bug. Changing a rule like "editors can now also publish" requires finding and updating every single location. There is no single source of truth for "can user X do action Y on resource Z?"

Over-Permission: Returning Too Much Data

Another common pitfall is returning more data than a user should see:

typescript
export async function GET(req: Request) {  const session = await getSession();  const documents = await db.document.findMany({    where: { projectId: req.params.projectId },    include: { author: true, reviews: true },  });
  // A viewer sees draft documents they shouldn't know about  // An author sees other authors' review feedback  // meant for editors only  return Response.json(documents);}

The query returns everything. No filtering based on what the user's role allows them to see. A viewer gets draft documents. An author gets review comments intended for editors. The API "works," but it leaks data.

Inconsistent Checks Across Layers

The same "can edit document" permission, checked differently in three places:

typescript
// UI component -- checks by role stringfunction EditButton({ user, document }: Props) {  const canEdit =    user.role === 'admin' ||    user.role === 'editor' ||    document.authorId === user.id;  // Missing: doesn't check project membership
  if (!canEdit) return null;  return <button>Edit</button>;}
// Server action -- checks differentlyasync function editDocument(docId: string, content: string) {  const session = await getSession();  const doc = await getDocument(docId);
  // Different logic: checks author but forgets editor role  if (    doc.authorId !== session.userId &&    session.user.role !== 'admin'  ) {    throw new Error('Cannot edit');  }  // ...}
// Middleware -- checks yet another wayexport function middleware(req: NextRequest) {  const session = getSessionFromCookie(req);  if (!session) {    return NextResponse.redirect(new URL('/login', req.url));  }  // Only checks authentication, not authorization  return NextResponse.next();}

The result: the Edit button shows for the right users, but clicking it may fail -- or worse, succeed when it shouldn't -- because the server-side check uses different logic than the client-side check.

Next.js-Specific Pitfalls

Layout-based authorization is a common mistake in Next.js applications:

typescript
// app/dashboard/layout.tsxexport default async function DashboardLayout({  children,}: {  children: React.ReactNode;}) {  const session = await getSession();  if (!session) {    redirect('/login');  }
  // Problem 1: Layouts don't re-render on client-side navigation.  // The session check runs once but not on subsequent route changes.
  // Problem 2: Server Actions in nested routes bypass this check.  // A server action can be called from anywhere.
  // Problem 3: API routes under /dashboard are not protected  // by this layout either.
  return <>{children}</>;}

Middleware-only authorization is another trap -- and a well-documented one. CVE-2025-29927 (CVSS 9.1) showed that the x-middleware-subrequest header in Next.js could be exploited to bypass all middleware-based access control entirely. Any protection that lived only in middleware could be circumvented with a single HTTP header.

typescript
// middleware.ts -- vulnerable patternexport function middleware(request: NextRequest) {  const session = getSessionFromCookie(request);
  if (request.nextUrl.pathname.startsWith('/admin')) {    if (!session || session.role !== 'admin') {      return NextResponse.redirect(        new URL('/login', request.url)      );    }  }
  return NextResponse.next();}// CVE-2025-29927: An attacker could add an// x-middleware-subrequest header to skip ALL middleware.// Authorization must happen at the data access layer.

This vulnerability demonstrates a fundamental principle: authorization must happen at the data access layer, not only at the middleware or routing level. Defense in depth -- checks at multiple layers -- is essential.

The "Fail Closed" Principle

When a permission check encounters an error -- a database timeout, an unexpected exception, a missing field -- there are two possible defaults:

Fail open (dangerous): Grant access when the check fails.

typescript
async function checkPermission(  userId: string,  resourceId: string): Promise<boolean> {  try {    const permission = await db.permission.findFirst({      where: { userId, resourceId },    });    return permission !== null;  } catch (error) {    console.error('Permission check failed:', error);    return true; // FAIL OPEN: grants access on error  }}

Fail closed (secure): Deny access when the check fails.

typescript
async function checkPermission(  userId: string,  resourceId: string): Promise<boolean> {  try {    const permission = await db.permission.findFirst({      where: { userId, resourceId },    });    return permission !== null;  } catch (error) {    console.error('Permission check failed:', error);    return false; // FAIL CLOSED: denies access on error  }}

A subtler version of this trap is the isAdmin boolean default:

typescript
// DANGEROUS: defaults to truelet isAdmin = true;try {  const user = await getUser(session.userId);  isAdmin = user.role === 'admin';} catch (error) {  logger.error('Failed to fetch user role', error);  // isAdmin remains true -- any error grants admin access}
// SECURE: defaults to falselet isAdmin = false;try {  const user = await getUser(session.userId);  isAdmin = user.role === 'admin';} catch (error) {  logger.error('Failed to fetch user role', error);  // isAdmin remains false -- error means no admin access}

The rule is straightforward: security methods like isAuthorized(), isAuthenticated(), and validate() should return false on any exception. When in doubt, deny access.

Warning: This applies to allowlists vs blocklists too. An allowlist ("only these roles can access") fails closed -- a new role has no access by default. A blocklist ("block these roles") fails open -- a new role has access until someone remembers to add it to the block list.

Goals of a Good Permission System

Before building a solution, it helps to define what a good permission system should achieve. These goals will guide the rest of this series:

1. Prevent Unauthorized Access

Both horizontal escalation (user A accessing user B's data) and vertical escalation (a viewer performing admin actions) must be prevented. Every endpoint and every data query needs an authorization check that considers the specific user, resource, and action.

2. Be Consistent

The same permission logic must apply everywhere. If an editor can edit documents in the UI, the same rule must apply in server actions, API routes, and database queries. One source of truth for permission rules eliminates drift between layers.

3. Auto-Enforce by Default

Developers should not need to remember to add permission checks. The system architecture should make unauthorized access impossible by default. If accessing data requires going through a permission-aware service layer, a missing check becomes a compilation error rather than a security vulnerability.

4. Be Easy to Update

Changing a permission rule -- like "editors can now also publish" -- should require a single change in a single place. If updating a permission rule requires searching the entire codebase for scattered checks, mistakes are inevitable.

5. Be Auditable

Know who accessed what and when. Compliance requirements in fintech, healthcare, and other regulated industries demand access audit trails. Even outside regulated industries, audit logs are invaluable for debugging permission issues.

6. Be Performant

Permission checks happen on every request, often multiple times per request. They cannot add significant latency. Caching strategies, efficient data structures, and minimal database queries are all considerations.

7. Be Type-Safe

In TypeScript, the type system should catch permission errors at compile time. If a role is renamed, the compiler should flag every reference. If a permission check requires a resource object but receives undefined, that should be a type error, not a runtime crash.

Real-World Scenarios: Permission Bugs in Practice

Let's walk through specific failure scenarios in the example application to see how these anti-patterns manifest.

Scenario 1: Horizontal Privilege Escalation

User A is an author in Project X. They change the document ID in the URL from /projects/x/documents/123 to /projects/y/documents/456. The page component checks "is the user authenticated?" but never checks "does this user have access to Project Y?"

User A now reads documents from a project they have no relationship to.

Scenario 2: Vertical Privilege Escalation

A viewer discovers that the "publish document" server action exists at a predictable endpoint. The UI hides the "Publish" button for viewers, but the server action only checks whether the user is authenticated -- not their role.

The viewer publishes documents by calling the server action directly.

Scenario 3: IDOR (Insecure Direct Object Reference)

The API endpoint /api/documents/123 returns the document if it exists in the database. No check for whether the requesting user has any relationship to the document or its project. Sequential IDs make enumeration trivial -- an attacker iterates through IDs and downloads every document in the system.

Scenario 4: Inconsistent Role Enforcement

The admin panel is accessible via the UI only to admins (a client-side check hides the navigation link). But the admin API routes (/api/admin/users, /api/admin/settings) lack role verification. Any authenticated user who knows the URL can call admin APIs directly.

Every one of these scenarios shares a root cause: authorization logic that is scattered, duplicated, or missing entirely.

Common Pitfalls Summary

ApproachAdvantageProblem
Ad-hoc scattered checksQuick to implement initiallyInconsistent, unmaintainable, creates security gaps
Middleware-only checksRuns on every requestBypassable (CVE-2025-29927), no resource-level context
Layout-based checksFeels centralizedDoes not re-render on navigation, does not protect server actions
Client-side-only checksGood UX (hides inaccessible UI)Zero security value -- server must always verify

What's Next

The anti-patterns in this post share a common root cause: authorization logic is scattered, duplicated, and inconsistent. There is no single source of truth for "can this user do this action on this resource?"

The solution is centralization. In the next post, we'll introduce the service layer pattern -- a single layer that all data access passes through, where authorization is checked once and checked correctly.

typescript
// Preview -- full implementation in Post 102// Instead of scattered checks everywhere,// all access goes through a permission-aware service:const document = await documentService.getById(  session.userId,  documentId);// The service handles: authentication, authorization,// and data filtering.// If the user can't access this document, the service// throws -- not the page.

Instead of checking permissions in pages, actions, and routes, you check them once in a service layer that sits between your application code and your database. No more scattered checks. No more inconsistencies. No more forgotten permission logic.

References

Permission Systems that Scale

A comprehensive guide to building scalable permission systems in TypeScript and Next.js, progressing from naive checks through RBAC and ABAC to production-grade multi-tenant authorization.

Progress1/6 posts completed

Related Posts