Authorisation and the Data Access Layer
AdvancedChecks 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.
// 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 existsThe Data Access Layer
One place that cannot be bypassed, because it reads the session itself.
// 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.
// 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 AriaWhat is a Data Access Layer and what problem does it solve?
Why return 404 instead of 403 for a record the user does not own?
Why is hiding a Pro feature in the UI insufficient?
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.