Authentication & Authorization Patterns

Layer optimistic middleware checks with real session verification in the Data Access Layer — and never authorize in only one place.

Auth in the App Router is best understood as layers of defense, not a single gate. Each layer does a different job, and skipping the last one is the mistake that ships vulnerabilities.

The three layers

  1. Middleware (optimistic) — a cheap, coarse check on a cookie's presence to redirect obviously-anonymous users before rendering. Fast UX, but never trust it for authorization: it can be bypassed and only sees the cookie, not its validity.
  2. Layouts & pages — verify the actual session where protected UI renders. Remember a layout does not re-run on every child navigation, so do not treat a layout check as protecting deeper pages on its own.
  3. Data Access Layer (the real boundary) — centralize every read/write behind functions that verify the session and check ownership before returning data. This is where authorization actually lives.

The Data Access Layer (DAL)

Put a verifySession() call at the top of every data function and mark the module import 'server-only'. Because Server Components, Server Actions, and Route Handlers all go through the DAL, the check happens no matter how the data is reached. Return only the fields a user is allowed to see (a DTO) rather than raw rows.

Example

Example · typescript
// lib/dal.ts — the Data Access Layer, the real authz boundary
import 'server-only';
import { cookies } from 'next/headers';
import { cache } from 'react';

export const verifySession = cache(async () => {
  const token = (await cookies()).get('session')?.value;
  const session = token ? await decrypt(token) : null;
  if (!session?.userId) return null;
  return { userId: session.userId };
});

export async function getUserPosts() {
  const session = await verifySession();
  if (!session) throw new Error('Unauthorized');
  // Ownership enforced here — not in the UI
  const rows = await db.post.findMany({
    where: { ownerId: session.userId },
  });
  // Return a DTO, not raw rows
  return rows.map((r) => ({ id: r.id, title: r.title }));
}

When to use it

  • A middleware provides a fast redirect for unauthenticated users, while a Data Access Layer re-checks the session before every database query.
  • A Server Action verifies the session and checks row-level ownership before allowing a delete, never trusting the middleware alone.
  • A developer uses a centralised getSession helper that throws if unauthenticated, so every protected component gets auth implicitly.

More examples

Data Access Layer session check

The DAL enforces auth at the data layer so no page or action can accidentally skip the check.

Example · ts
// lib/dal.ts
import 'server-only';
import { getSession } from './auth';

export async function requireUser() {
  const session = await getSession();
  if (!session?.userId) throw new Error('Unauthenticated');
  return session;
}

export async function getUserPosts(userId: string) {
  const session = await requireUser();
  if (session.userId !== userId) throw new Error('Forbidden');
  return db.post.findMany({ where: { authorId: userId } });
}

Middleware + DAL dual check

Middleware provides a UX shortcut redirect; the DAL does the cryptographic verification that actually matters.

Example · ts
// middleware.ts — fast redirect (optimistic)
export function middleware(req: NextRequest) {
  const token = req.cookies.get('token')?.value;
  if (!token) return NextResponse.redirect(new URL('/login', req.url));
  return NextResponse.next();
}

// app/dashboard/page.tsx — real verification
import { requireUser } from '@/lib/dal';
export default async function DashboardPage() {
  const user = await requireUser(); // throws if invalid token
  return <p>Welcome {user.name}</p>;
}

Role-based access in a page

Checking the role after requireUser() enforces fine-grained authorization beyond simple authentication.

Example · ts
// app/admin/page.tsx
import { requireUser } from '@/lib/dal';
import { redirect } from 'next/navigation';

export default async function AdminPage() {
  const user = await requireUser();
  if (user.role !== 'admin') redirect('/unauthorized');
  return <h1>Admin Panel</h1>;
}

Discussion

  • Be the first to comment on this lesson.