Centralizing Authorization with a Service Layer
Refactor scattered permission checks into a centralized service layer, add Next.js middleware guards, and build a defense-in-depth authorization architecture.
Abstract
In Post 101, we diagnosed the root problems with scattered permission checks: inconsistent logic, security gaps, and maintenance overhead. This post delivers the first solution -- the service layer pattern. All authorization logic moves into a single layer between your application code and the database. Combined with Next.js middleware for route protection and guard patterns at multiple levels, this creates a defense-in-depth architecture where permission checks happen once and happen correctly.
The Scattered Checks Problem -- Root Cause
Post 101 showed three code examples where the same "can this user access this resource?" question was answered differently in a page component, a server action, and an API route. Each had a different bug. The symptoms were inconsistency, missing checks, and over-permission.
The root cause is architectural: there is no chokepoint through which all data access must pass. Pages, server actions, and API routes all access the database directly. Permission checks are optional additions to each entry point. Developers must remember to add them -- and "remember" is not a security strategy.
The fix is to create that chokepoint. Instead of placing security guards at every door and hoping none are absent, funnel all traffic through a single checkpoint.
The Service Layer Pattern
Definition
Martin Fowler defines a Service Layer as a boundary that "establishes a set of available operations and coordinates the application's response in each operation." In authorization context, the service layer is the single place where permission decisions are made and enforced.
This maps directly to Next.js's recommended Data Access Layer (DAL) pattern from the official documentation. The core idea is the same: create a server-only layer that combines three responsibilities:
- Authentication verification -- is the user who they claim to be?
- Authorization checks -- is this user allowed to perform this action on this resource?
- Data access and transformation -- fetch the data and return only what the user should see.
Single Responsibility Principle
A common misconception is that SRP means "one function does one thing." It means "one module has one reason to change." The service layer's reason to change is when permission rules or data access patterns change. Pages change for UI reasons. Server actions change for form handling reasons. Neither should carry authorization logic.
Architecture Overview
Key technical details:
- The
server-onlypackage prevents service layer code from being bundled into client components. Importing it in a file that gets imported by a Client Component will cause a build error. - React's
cache()function memoizes the session verification within a single request/render pass. Multiple components callingverifySession()result in only one cookie decryption operation.
Refactoring: From Scattered Checks to Centralized Service
Step 1: Session Verification
The foundation is a memoized session verification utility. Every service method calls this first.
Two functions, two purposes. verifySession() returns null for unauthenticated requests -- useful for components that render differently for logged-in vs anonymous users. requireSession() redirects immediately -- useful for protected pages and service methods.
Step 2: Custom Error Classes
Before building services, define structured error types. These replace generic throw new Error('Unauthorized') calls with specific, catchable errors.
Tip: The
NotFoundErroruses a generic message intentionally. Returning "Document not found" vs "Access denied" reveals whether a resource exists. For sensitive resources, combine both into a single message to prevent enumeration attacks.
Step 3: DocumentService
This is the core refactoring. Compare the scattered checks from Post 101 to the centralized service.
Before -- authorization scattered in a server action (from Post 101):
After -- centralized in the service layer:
The permission logic, extracted into focused helper functions:
Notice the pattern. Every service method follows the same structure:
- Verify the session
- Fetch the resource with related data
- Check authorization
- Return a filtered DTO
Step 4: ProjectService
The same pattern applies to projects. One service file per resource type keeps the codebase organized.
Step 5: DTO Pattern for Safe Data Transfer
Raw database objects contain fields that should not reach the client. Internal IDs, timestamps, soft-delete flags, and metadata intended for other roles all leak when you return the database model directly.
The DTO (Data Transfer Object) pattern solves this. Each service method returns a filtered object based on the user's role.
This solves the over-permission problem from Post 101. A viewer gets title and content. An editor gets title, content, and review comments. The same resource, different views -- controlled in one place.
Step 6: Thin Server Actions
With the service layer in place, server actions become thin wrappers. They handle form data and call the service. Nothing else.
Compare this to the buggy server action from Post 101. The action no longer contains any permission logic. It cannot have permission bugs because it delegates everything to the service layer.
Next.js Middleware for Route Protection
Note: In Next.js 16,
middleware.tshas been renamed toproxy.tsand the exported function toproxy. The patterns and concepts in this section apply to both. If you are on Next.js 15 or earlier, usemiddleware.ts.
What Middleware Should Do
Middleware runs before every matched request. It is fast and has access to cookies and headers. This makes it suited for one job: optimistic authentication checks.
- Check if a session cookie exists
- Redirect unauthenticated users away from protected routes
- Check coarse-grained role claims from the session token (like "is this user an admin?")
- Act as the first layer of defense -- not the last
What Middleware Should NOT Do
- Be the sole authorization mechanism (CVE-2025-29927 demonstrated why)
- Query the database for permission checks (performance concern -- runs on every request)
- Perform resource-level authorization (middleware has no resource context -- it does not know which document the user is trying to access)
Implementation
This middleware is intentionally simple. It checks cookie presence and session validity. It does not query the database. It does not perform resource-level authorization. Those responsibilities belong to the service layer.
Defense in Depth
No single layer is sufficient. Each layer covers for potential failures in the others.
- Layer 1 -- Middleware: Fast rejection of obviously unauthenticated requests. Optimistic check based on session cookies.
- Layer 2 -- Service Layer: Definitive authorization with full resource context. Checks user identity, role, project membership, and document ownership.
- Layer 3 -- Database: Constraints like foreign keys and row-level security policies (covered in a future post) provide a final safety net.
If middleware is bypassed (as in CVE-2025-29927), the service layer still blocks unauthorized access. If a service layer bug grants access to the wrong resource, database constraints can catch the inconsistency. Each layer independently provides protection.
Guard Patterns
Route-Level Guards (Middleware)
Already covered above. Middleware redirects unauthenticated users before the page renders. This is coarse-grained: "Is the user logged in?" and "Is this a protected route?"
Page-Level Guards (Server Components)
Page components call service methods directly. The service handles authentication, authorization, and data access in one call.
Compare this to Post 101's version, where the page component contained all the permission logic inline. The page is now thin. It calls the service and renders the result. If the user cannot access this project, the service throws -- not the page.
Component-Level Guards (Conditional Rendering)
Server Components can call verifySession() and conditionally render based on role. This is for UI/UX purposes only -- not security. The service layer is the security boundary.
Warning: This component-level check duplicates the service layer logic. If the edit permission rule changes in the service, it must also change here. This duplication is a known limitation of the current approach. Post 103 (RBAC) solves this by making permission rules queryable from a single source.
Server Action Guards (Higher-Order Wrapper)
A withAuth higher-order function wraps server actions with authentication. This ensures every action verifies the session before execution.
Usage in a server action:
The withAuth wrapper guarantees the session exists before the action body runs. Combined with authorization inside the service method, this provides two layers of protection for every server action.
Testability
One of the biggest wins of the service layer is testability. Permission logic is isolated from rendering, routing, and request handling. Tests have clear inputs and outputs.
Each test verifies a specific permission rule in isolation. No page rendering. No request context. No form handling. Just: "Given user X with role Y accessing document Z, expect allow or deny."
Compare this to testing scattered checks. You would need to render pages, call server actions with full request contexts, and set up authentication middleware. The service layer makes the most critical logic -- permissions -- the easiest to test.
Benefits and Limitations
What the Service Layer Solves
What Remains Unsolved
The service layer solves where permission checks happen. But the permission rules themselves are still hardcoded if/else statements. This creates several limitations:
- Hardcoded role checks: Permission logic is
if (role === 'admin')inside service methods. Adding a new role requires modifying every relevant service method. - No permission matrix: There is no declarative, inspectable list of "role X can do action Y on resource Z." Permission rules are embedded in code logic.
- Component-level duplication: UI guard checks duplicate service layer logic. Keeping them in sync is manual and error-prone.
- Role changes require code changes: Adding a "moderator" role that can publish but not delete requires touching multiple service methods.
- No audit trail: The service layer enforces permissions but does not log who accessed what and when.
These limitations point directly to the next step: replacing hardcoded if/else with a structured permission system.
Common Pitfalls
-
"Just this once" database bypass: Every direct database access outside the service layer is a potential authorization gap. Enforce the pattern through code review and consider a custom ESLint rule that flags direct database imports outside service files.
-
Forgetting
server-only: Without this import, service layer code could be imported by a Client Component. This exposes internal logic and database access patterns to the client bundle. -
Skipping
cache()for session verification: Without memoization, callingverifySession()in a page component and a child server component results in two cookie decryption operations per request. -
Returning raw database objects: Always return DTOs from service methods. Raw objects may contain timestamps, soft-delete flags, or internal fields that should not reach the client.
-
Generic error messages that leak resource existence:
throw new Error('Document not found')reveals that the resource does not exist. For sensitive resources, use "not found or access denied" to prevent enumeration.
What's Next
The service layer is the correct architecture for authorization. All data access flows through permission-aware services. Middleware provides an additional defense layer. Guard patterns compose at multiple levels.
But the permission rules inside the service layer are still hardcoded if/else statements. Changing a rule means modifying code. Adding a role means touching every service method. There is no way to inspect all permissions at a glance.
In Post 103, we introduce Role-Based Access Control (RBAC). Instead of hardcoded conditionals, permission rules become data -- a structured matrix that both the service layer and UI can query.
The service layer remains. Only the internals of the permission checks change.
References
- Martin Fowler - Service Layer - The foundational definition of the Service Layer pattern from Patterns of Enterprise Application Architecture
- Next.js Authentication Guide (Official) - Official Next.js documentation covering the Data Access Layer pattern,
verifySession()withcache(), and DTO recommendations - Next.js Data Security Guide (Official) - Official documentation on
server-only, DTO patterns, React Taint APIs, and the three data fetching approaches - How to Think About Security in Next.js (Official Blog) - Next.js team guidance on the security model for Server Components and Server Actions
- CVE-2025-29927: Next.js Middleware Authorization Bypass - Technical analysis of the CVSS 9.1 vulnerability demonstrating why middleware must not be the sole authorization layer
- OWASP Authorization Cheat Sheet - Best practices for centralized authorization logic and defense-in-depth
- OWASP Fail Securely - Why security methods should return false on exception and the fail-closed principle
- Defense in Depth (Cloudflare) - Explanation of the layered security model applied in this post
- Authorization in Next.js - Robin Wieruch - Comprehensive guide to layered authorization in Next.js App Router
- Next.js Server Actions Security - MakerKit - Guide covering Server Action vulnerabilities and the
next-safe-actionmiddleware composition pattern - Understanding the Data Access Layer in Next.js - Practical breakdown of the DAL pattern including
server-onlyusage andcache()for request deduplication
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.