Home/Learn/Next.js/Authorisation and the Data Access Layer

Authorisation and the Data Access Layer

Advanced
Auth & Security

Checks scattered across pages, actions and handlers get forgotten. Routing every read through one layer that requires the session makes forgetting impossible.

Overview

Authentication asks who you are once; authorisation asks what you may do at every entry point — each page, each action, each handler, each of which is independently reachable. The failure mode is not a wrong check, it is a missing one on the endpoint nobody remembered. The Data Access Layer pattern fixes it structurally: every query lives behind a function that reads the session itself, so a component cannot fetch data without being authorised, and a new endpoint inherits the check for free.

Check at Every Entry Point

Each is reachable on its own, so each needs its own check.

Page, action, handler — and ownership every time
// A page
export default async function AdminPage() {
  const user = await requireUser()
  if (user.role !== 'admin') return <Forbidden />
}

// A server action — a public POST endpoint, remember
'use server'
export async function deleteProblem(id: string) {
  const user = await requireUser()
  if (user.role !== 'admin') throw new Error('Forbidden')
}

// A route handler
export async function DELETE(req: NextRequest) {
  const user = await getSession()
  if (!user) return new NextResponse('Unauthorized', { status: 401 })
}

// Middleware may redirect for convenience, but it is NOT the check —
// see the middleware concept and CVE-2025-29927.

// And the check that is actually forgotten most often is ownership,
// not role:
const submission = await db.submission.findUnique({ where: { id } })
return submission                                   // whose?
const submission = await db.submission.findUnique({
  where: { id, userId: user.id },                   // theirs
})
if (!submission) notFound()                          // 404, not 403 —
                                                     // do not confirm it exists

The Data Access Layer

One place that cannot be bypassed, because it reads the session itself.

server-only, requireUser inside, DTOs out
// lib/dal.ts
import 'server-only'                    // a client import fails the build

export const getProblemsForUser = cache(async () => {
  const user = await requireUser()      // the DAL authorises itself
  return db.problem.findMany({
    where: { OR: [{ authorId: user.id }, { published: true }] },
  })
})

export const getSubmission = cache(async (id: string) => {
  const user = await requireUser()
  return db.submission.findUnique({ where: { id, userId: user.id } })
})

// A page cannot get data without going through it:
export default async function Page() {
  const problems = await getProblemsForUser()      // already authorised
  return <ProblemList problems={problems} />
}

// Two properties that make this worth the indirection:
//   1. A new page, action or handler inherits the check automatically.
//   2. Reviewing authorisation means reading one file, not grepping
//      for db. across the codebase.

// Enforce it: an ESLint rule banning direct db imports outside
// lib/dal.ts turns the convention into a build failure.

// Also strip fields here, not in the component — a DTO at the
// boundary, so passwordHash cannot reach a client component's props:
return { id: u.id, name: u.name, role: u.role }

Tiers and Feature Gates

The same rule applied to Pro, Elite and Campus.

Strip gated fields server-side, not in the component
// Hiding a locked feature in the UI is a courtesy, not a control.
{isPro ? <Solution /> : <UpgradePrompt />}

// The server must also refuse to SEND the gated content — otherwise
// it is in the HTML payload for anyone who opens DevTools:
const problem = await getProblem(slug)
const canSeeSolution = user?.isPro || user?.role === 'admin'
return (
  <ProblemView
    problem={{
      ...problem,
      solution: canSeeSolution ? problem.solution : null,   // stripped
      hints: canSeeSolution ? problem.hints : [],
    }}
  />
)

// Compute entitlements once, server-side, and pass the booleans down.
// The client re-deriving "is this user Pro" from a plan string is how
// the two sides drift apart.

// Tier checks belong in the DAL too:
export const getSolution = cache(async (slug: string) => {
  const user = await requireUser()
  if (!user.isPro) throw new ForbiddenError('pro_required')
  return db.problem.findUnique({ where: { slug }, select: { solution: true } })
})

// And keep the metered limits on the server. A client-side counter is
// a suggestion; the server's count is the limit.

Key Points to Remember

  • 1Every page, action and route handler is independently reachable and needs its own authorisation check
  • 2Ownership is the check most often forgotten — put the user id in the query and return 404 rather than 403
  • 3A Data Access Layer that reads the session itself makes a missing check structurally impossible
  • 4Mark the DAL server-only and return DTOs, so sensitive fields cannot reach a client component's props
  • 5Gating in the UI still ships the data — strip Pro-only fields on the server and compute entitlements there

Interview Questions

Sign in to ask Aria
1

What is a Data Access Layer and what problem does it solve?

Hard
2

Why return 404 instead of 403 for a record the user does not own?

Hard
3

Why is hiding a Pro feature in the UI insufficient?

Medium

Ask Aria about Authorisation and the Data Access Layer

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…