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
401for permission failures or403for 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:
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):
Server action (app/actions/documents.ts):
API route (app/api/projects/[id]/documents/route.ts):
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:
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:
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:
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.
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.
Fail closed (secure): Deny access when the check fails.
A subtler version of this trap is the isAdmin boolean default:
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
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.
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
- OWASP Top 10:2025 - A01 Broken Access Control - Broken access control is the number one web application security risk, confirmed in both the 2021 and 2025 editions
- OWASP Authorization Cheat Sheet - Comprehensive best practices for authorization including deny by default and server-side enforcement
- OWASP Fail Securely - Why security methods should return false on exception, with the isAdmin default trap example
- CVE-2025-29927: Next.js Middleware Authorization Bypass (ProjectDiscovery) - Technical analysis of the CVSS 9.1 vulnerability allowing middleware bypass via the x-middleware-subrequest header
- CVE-2025-29927: Next.js Authorization Bypass (JFrog) - Detailed breakdown of affected versions, exploit mechanism, and mitigation strategies
- Next.js Authentication Guide (Official) - Official documentation covering the Data Access Layer pattern, middleware limitations, and server action security
- Authorization Bugs Are Having Their SQL Injection Moment (ZeroPath) - Analysis showing 53% of critical vulnerabilities are access control flaws with near-zero SAST detection rates
- How to Avoid Common Authorization Errors (Cerbos) - Catalog of authorization anti-patterns including over-permissions and inconsistent logic
- Understanding Fail Open and Fail Closed (AuthZed) - Detailed explanation of fail-open vs fail-closed patterns with code examples
- Authentication vs. Authorization (Microsoft Identity Platform) - Authoritative documentation on the authentication and authorization distinction
- Google Zanzibar Paper - Google's global authorization system design, providing context for centralized permission architectures
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.