Authorization

Where authentication establishes who is making a request, authorization decides what that particular, already-identified user is allowed to see or do. A signed-in user is not automatically entitled to every page or every record — authorization is the set of checks that enforce that distinction: role checks ("is this user an admin?"), permission checks ("can this user edit this document?"), and ownership checks ("does this record belong to this user?").

Coarse checks in Middleware, fine checks close to the data

Next.js Middleware runs before a request reaches a route, which makes it a convenient place to redirect obviously unauthorized requests before any rendering work happens — for example, sending anyone without a session cookie away from an entire /admin route group. But Middleware runs on the Edge Runtime, matches routes by pattern, and typically can't afford a database round-trip on every request, so it's best suited to coarse, structural checks: is there a session at all, does its role claim look right.

Fine-grained checks — does this specific user own this specific record, does their role actually grant this specific action — belong in the Server Component, Server Action, or Route Handler that's about to touch the data. This is defense in depth: a Middleware matcher misconfiguration, a route added after the matcher was written, or a direct call to a Server Action from outside the page it's normally attached to should never be the only thing standing between a request and data it shouldn't reach. Treat every Server Action and Route Handler as if it could be called directly, because it can be.



Protecting routes and redirecting unauthorized users

Two outcomes are worth distinguishing, and Next.js has conventions for both:

Enabling the experimental interrupt APIs used by forbidden() and unauthorized().
next.config.ts:
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    authInterrupts: true,
  },
}

export default nextConfig
A coarse, structural check in Middleware: no session cookie means no access to the protected route group, full stop. It does not attempt to inspect roles or permissions.
middleware.ts:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const hasSession = request.cookies.has('session')

  if (!hasSession) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', request.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/admin/:path*', '/dashboard/:path*'],
}
A fine-grained role check made where the data is actually accessed, not relying on Middleware alone.
app,admin,users,page.tsx:
import { forbidden } from 'next/navigation'
import { requireUser } from '@/lib/auth'

export default async function AdminUsersPage() {
  const user = await requireUser() // redirects to /login if not authenticated

  if (user.role !== 'admin') {
    forbidden() // renders app/forbidden.tsx
  }

  // safe to fetch and render admin-only data below
  return <p>Admin user list.</p>
}

If your Next.js version doesn't yet support authInterrupts, the same intent can be expressed by redirecting to a dedicated "not permitted" page or by returning a 403 response from a Route Handler — the underlying principle (check permissions at the point of access, and fail in a way that's explicit rather than a silent empty page) matters more than the specific API.