Skip to content

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:

  1. Authentication verification -- is the user who they claim to be?
  2. Authorization checks -- is this user allowed to perform this action on this resource?
  3. 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-only package 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 calling verifySession() 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.

typescript
// lib/auth.tsimport 'server-only';import { cache } from 'react';import { cookies } from 'next/headers';import { redirect } from 'next/navigation';
// Memoized within a single request/render passexport const verifySession = cache(async () => {  const cookieStore = await cookies();  const token = cookieStore.get('session')?.value;
  if (!token) {    return null;  }
  try {    const session = await decryptAndValidate(token);    return { userId: session.userId, role: session.role };  } catch {    return null; // Fail closed -- invalid token means no session  }});
// Convenience wrapper that redirects unauthenticated usersexport const requireSession = cache(async () => {  const session = await verifySession();  if (!session) {    redirect('/login');  }  return session;});

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.

typescript
// lib/errors.tsexport class UnauthorizedError extends Error {  constructor(message = 'Authentication required') {    super(message);    this.name = 'UnauthorizedError';  }}
export class ForbiddenError extends Error {  constructor(message = 'Access denied') {    super(message);    this.name = 'ForbiddenError';  }}
export class NotFoundError extends Error {  // Use generic message to prevent resource enumeration  constructor(message = 'Resource not found or access denied') {    super(message);    this.name = 'NotFoundError';  }}

Tip: The NotFoundError uses 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):

typescript
// BEFORE: Permission check scattered in server action'use server';export async function updateDocument(  documentId: string,  content: string) {  const session = await getSession();  const document = await db.document.findUnique({    where: { id: documentId },  });  if (document.authorId !== session.userId) {    // Bug: forgot editors can edit too    throw new Error('Unauthorized');  }  // Bug: no project membership check  await db.document.update({    where: { id: documentId },    data: { content },  });}

After -- centralized in the service layer:

typescript
// lib/services/document-service.tsimport 'server-only';import { requireSession } from '@/lib/auth';import { ForbiddenError, NotFoundError } from '@/lib/errors';
export async function getDocumentById(documentId: string) {  const session = await requireSession();
  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: { include: { members: true } } },  });
  if (!document) {    throw new NotFoundError();  }
  if (!canAccessDocument(session, document)) {    throw new ForbiddenError();  }
  return toDocumentDTO(document, session);}
export async function updateDocument(  documentId: string,  content: string) {  const session = await requireSession();
  const document = await db.document.findUnique({    where: { id: documentId },    include: { project: { include: { members: true } } },  });
  if (!document) {    throw new NotFoundError();  }
  if (!canEditDocument(session, document)) {    throw new ForbiddenError();  }
  const updated = await db.document.update({    where: { id: documentId },    data: { content },  });
  return toDocumentDTO(updated, session);}
export async function listDocumentsByProject(  projectId: string) {  const session = await requireSession();
  const project = await db.project.findUnique({    where: { id: projectId },    include: { members: true },  });
  if (!project) {    throw new NotFoundError();  }
  if (!canAccessProject(session, project)) {    throw new ForbiddenError();  }
  const documents = await db.document.findMany({    where: { projectId },  });
  // Filter documents based on user's access level  return documents    .filter((doc) => canAccessDocument(session, {      ...doc,      project,    }))    .map((doc) => toDocumentDTO(      { ...doc, project },      session    ));}

The permission logic, extracted into focused helper functions:

typescript
// Permission helpers within document-service.ts
function canAccessDocument(  session: { userId: string; role: string },  document: DocumentWithProject): boolean {  if (session.role === 'admin') return true;
  const project = document.project;
  // Public projects: published documents visible to all  if (    project.visibility === 'public' &&    document.status === 'published'  ) {    return true;  }
  // Must be a project member for anything else  const membership = project.members.find(    (m) => m.userId === session.userId  );  if (!membership) return false;
  // Members see published documents  if (document.status === 'published') return true;
  // Authors see their own drafts  if (document.authorId === session.userId) return true;
  // Editors see all documents including drafts  if (membership.role === 'editor') return true;
  return false; // Fail closed}
function canEditDocument(  session: { userId: string; role: string },  document: DocumentWithProject): boolean {  if (session.role === 'admin') return true;
  const membership = document.project.members.find(    (m) => m.userId === session.userId  );  if (!membership) return false;
  // Editors can edit any document in the project  if (membership.role === 'editor') return true;
  // Authors can edit their own documents  if (    membership.role === 'author' &&    document.authorId === session.userId  ) {    return true;  }
  return false; // Fail closed}

Notice the pattern. Every service method follows the same structure:

  1. Verify the session
  2. Fetch the resource with related data
  3. Check authorization
  4. Return a filtered DTO

Step 4: ProjectService

The same pattern applies to projects. One service file per resource type keeps the codebase organized.

typescript
// lib/services/project-service.tsimport 'server-only';import { requireSession } from '@/lib/auth';import { ForbiddenError, NotFoundError } from '@/lib/errors';
export async function getProjectById(projectId: string) {  const session = await requireSession();
  const project = await db.project.findUnique({    where: { id: projectId },    include: { members: true },  });
  if (!project) {    throw new NotFoundError();  }
  if (!canAccessProject(session, project)) {    throw new ForbiddenError();  }
  return toProjectDTO(project, session);}
export async function listProjectsForUser() {  const session = await requireSession();
  if (session.role === 'admin') {    const projects = await db.project.findMany({      include: { members: true },    });    return projects.map((p) => toProjectDTO(p, session));  }
  const projects = await db.project.findMany({    where: {      OR: [        { visibility: 'public' },        { members: { some: { userId: session.userId } } },        { ownerId: session.userId },      ],    },    include: { members: true },  });
  return projects.map((p) => toProjectDTO(p, session));}
function canAccessProject(  session: { userId: string; role: string },  project: ProjectWithMembers): boolean {  if (session.role === 'admin') return true;  if (project.visibility === 'public') return true;  if (project.ownerId === session.userId) return true;
  const membership = project.members.find(    (m) => m.userId === session.userId  );  return !!membership; // Fail closed}

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.

typescript
// lib/services/document-service.ts
interface DocumentDTO {  id: string;  title: string;  content: string;  status: string;  projectId: string;  authorId: string;  reviewComments?: string[];}
function toDocumentDTO(  document: DocumentWithProject,  session: { userId: string; role: string }): DocumentDTO {  const membership = document.project.members.find(    (m) => m.userId === session.userId  );
  const base: DocumentDTO = {    id: document.id,    title: document.title,    content: document.content,    status: document.status,    projectId: document.projectId,    authorId: document.authorId,  };
  // Editors and admins see review metadata  if (    session.role === 'admin' ||    membership?.role === 'editor'  ) {    return {      ...base,      reviewComments: document.reviewComments,    };  }
  return base;}

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.

typescript
// app/actions/documents.ts'use server';import { updateDocument } from '@/lib/services/document-service';
export async function updateDocumentAction(  documentId: string,  content: string) {  // All authorization happens inside the service  return updateDocument(documentId, content);}

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.ts has been renamed to proxy.ts and the exported function to proxy. The patterns and concepts in this section apply to both. If you are on Next.js 15 or earlier, use middleware.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

typescript
// middleware.tsimport { NextRequest, NextResponse } from 'next/server';import { decrypt } from '@/lib/session';
const publicRoutes = ['/', '/login', '/signup'];const adminRoutes = ['/admin'];
export async function middleware(request: NextRequest) {  const path = request.nextUrl.pathname;
  // Allow public routes without authentication  if (publicRoutes.some((route) => path === route)) {    return NextResponse.next();  }
  // Check session cookie (optimistic -- no DB call)  const cookie = request.cookies.get('session')?.value;  const session = await decrypt(cookie);
  if (!session?.userId) {    return NextResponse.redirect(      new URL('/login', request.url)    );  }
  // Admin routes: check role from session token  if (adminRoutes.some((route) => path.startsWith(route))) {    if (session.role !== 'admin') {      return NextResponse.redirect(        new URL('/', request.url)      );    }  }
  return NextResponse.next();}
export const config = {  matcher: [    '/((?!api|_next/static|_next/image|.*\\.png$).*)',  ],};

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.

typescript
// app/projects/[id]/page.tsximport { getProjectById } from '@/lib/services/project-service';
export default async function ProjectPage({  params,}: {  params: Promise<{ id: string }>;}) {  const { id } = await params;
  // Service handles: session + authorization + data access  const project = await getProjectById(id);
  return <ProjectView project={project} />;}

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.

typescript
// components/document-actions.tsximport { verifySession } from '@/lib/auth';
export async function DocumentActions({  document,}: {  document: DocumentDTO;}) {  const session = await verifySession();  if (!session) return null;
  const canEdit =    session.role === 'admin' ||    session.role === 'editor' ||    document.authorId === session.userId;
  return (    <div>      {canEdit && <EditButton documentId={document.id} />}      {session.role === 'admin' && (        <DeleteButton documentId={document.id} />      )}    </div>  );}

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.

typescript
// lib/action-guard.tsimport { requireSession } from '@/lib/auth';
type ActionFn<TInput, TOutput> = (  session: { userId: string; role: string },  input: TInput) => Promise<TOutput>;
export function withAuth<TInput, TOutput>(  action: ActionFn<TInput, TOutput>) {  return async (input: TInput): Promise<TOutput> => {    const session = await requireSession();    return action(session, input);  };}

Usage in a server action:

typescript
// app/actions/documents.ts'use server';import { withAuth } from '@/lib/action-guard';import { updateDocument } from '@/lib/services/document-service';
export const updateDocumentAction = withAuth(  async (session, input: { documentId: string; content: string }) => {    return updateDocument(input.documentId, input.content);  });

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.

typescript
// __tests__/services/document-service.test.tsimport { getDocumentById } from '@/lib/services/document-service';import { ForbiddenError } from '@/lib/errors';
// Mock session for different user rolesjest.mock('@/lib/auth', () => ({  requireSession: jest.fn(),}));
describe('getDocumentById', () => {  it('allows admin to access any document', async () => {    mockSession({ userId: 'admin-1', role: 'admin' });    mockDocument({ id: 'doc-1', status: 'draft' });
    const result = await getDocumentById('doc-1');    expect(result.id).toBe('doc-1');  });
  it('denies viewer access to draft documents', async () => {    mockSession({ userId: 'viewer-1', role: 'viewer' });    mockDocument({      id: 'doc-1',      status: 'draft',      authorId: 'author-1',    });
    await expect(      getDocumentById('doc-1')    ).rejects.toThrow(ForbiddenError);  });
  it('allows author to access their own draft', async () => {    mockSession({ userId: 'author-1', role: 'author' });    mockDocument({      id: 'doc-1',      status: 'draft',      authorId: 'author-1',    });
    const result = await getDocumentById('doc-1');    expect(result.id).toBe('doc-1');  });});

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

Post 101 ProblemService Layer Solution
Scattered checks in 3+ locationsSingle location per resource operation
Inconsistent logic across layersOne implementation, called everywhere
Missing checks on new endpointsNew endpoints must call service methods
Over-permission (returning too much data)DTO pattern filters data by role
Difficult to test permission logicService methods are independently testable
No single source of truthService layer IS the source of truth

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:

  1. Hardcoded role checks: Permission logic is if (role === 'admin') inside service methods. Adding a new role requires modifying every relevant service method.
  2. 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.
  3. Component-level duplication: UI guard checks duplicate service layer logic. Keeping them in sync is manual and error-prone.
  4. Role changes require code changes: Adding a "moderator" role that can publish but not delete requires touching multiple service methods.
  5. 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

  1. "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.

  2. 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.

  3. Skipping cache() for session verification: Without memoization, calling verifySession() in a page component and a child server component results in two cookie decryption operations per request.

  4. 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.

  5. 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.

typescript
// Preview -- full implementation in Post 103// Instead of:if (role === 'admin' || role === 'editor') {  // allow edit}// We'll use:if (hasPermission(session.role, 'document', 'update')) {  // allow edit}

The service layer remains. Only the internals of the permission checks change.

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.

Progress2/6 posts completed

Related Posts