Skip to content
advanced Phase 10 · Authentication and Sessions

Role-Based Access Control

Gate pages and actions by user roles and permissions.

50m
0 problems
Topic Progress 0%

Storing Roles on the User Record and Guarding the Page That Renders It

Authentication tells you who is asking. Authorization decides what they may do, and the data model for that decision is smaller than most teams expect: roles stored with the user record decide which pages and actions that user may reach.

enum Role {
  USER
  SUPPORT
  ADMIN
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  passwordHash String
  role      Role     @default(USER)     // deny by default: new sign-ups are nobody
  posts     Post[]
}

Start with a single column, not a permissions matrix. A role enum on User answers the question you actually ask in code ("may this user open the admin console?"), it is one join-free read, and it is trivially auditable in SQL. Permission tables (Role → Permission many-to-many) earn their complexity only when non-engineers edit policies or when the same action is gated differently per tenant — most products grow into that, they rarely start there. Record who changed a role while you are at it: columns like roleUpdatedAt and roleChangedById turn the first post-incident question — "who made this account an admin, and when?" — into a single query instead of a log-file archaeology dig.

Now the read. The role can come from two places, and the choice has a consequence interviewers like to probe:

// app/admin/users/page.tsx
import { auth } from "@/auth"
import { notFound, redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"

export default async function AdminUsersPage() {
  const session = await auth()
  if (!session) redirect("/login")

  // Role read fresh from the database: a demotion applies on the very next
  // request, instead of waiting out whatever exp the signed token carries.
  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { role: true },
  })
  if (user?.role !== "ADMIN") notFound()      // 404, not 403 — chapter three

  const users = await prisma.user.findMany({ select: { id: true, email: true, role: true } })
  return <UserTable users={users} />
}

If the role rides in the verified JWT, the check is free and works in edge middleware — but a demotion does not take effect until the token expires, which is the trade-off Verifying JWTs on the Server spelled out. Read the role from the database when the decision is expensive or the change must apply immediately; use the token's claim for cheap, low-stakes gates like showing or hiding a nav item. Authorization checks belong on the server, where the client cannot bypass them, and this page is the canonical place: it runs before markup exists, it scopes the query, and no disabled attribute can talk it out of anything.

Two structural notes keep the guard honest. Put the check in the page as well as the layout — layouts persist across navigations and can be reused without re-running, so a layout-only guard is not re-evaluated on every visit. And make denial the default branch: every new route starts life as "no role matches," and protection is granted explicitly, which fails closed if someone forgets a case. A helper keeps it consistent across dozens of pages:

// lib/authz.ts
export async function requireRole(...allowed: Role[]) {
  const session = await auth()
  if (!session) redirect("/login")
  const { role } = await prisma.user.findUniqueOrThrow({
    where: { id: session.user.id }, select: { role: true },
  })
  if (!allowed.includes(role)) notFound()
  return session
}

findUniqueOrThrow is deliberate: a user row deleted mid-session should deny, not fall through to an undefined role that matches nothing by accident. Guarding the render is only half; the mutation is where attackers actually push, and that is the next chapter.

Guarding the Mutation: Where Cheating Actually Happens

Hiding the admin page proves nothing. A server action is a POST endpoint with a generated action id — anyone can call it from curl with a valid session cookie and no HTML required — so a check that exists only in the component is a check that does not exist.

// app/admin/actions.ts
"use server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { revalidatePath } from "next/cache"

export async function suspendUser(userId: string) {
  const session = await auth()
  if (!session) throw new Error("unauthenticated")          // 401 equivalent
  if (session.user.role !== "ADMIN") throw new Error("forbidden")  // 403 equivalent

  await prisma.user.update({
    where: { id: userId },
    data: { suspendedAt: new Date() },
  })
  revalidatePath("/admin/users")
}

And the same rule in a route handler, where you control the status codes precisely:

// app/api/admin/users/[id]/route.ts
export async function DELETE(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth()
  if (!session) return Response.json({ error: "unauthenticated" }, { status: 401 })

  if (session.user.role !== "ADMIN")
    return Response.json({ error: "forbidden" }, { status: 403 })

  const { id } = await params
  await prisma.user.delete({ where: { id } })
  return new Response(null, { status: 204 })
}

Use the status-code vocabulary consistently, because it is what monitoring, WAFs, and API clients key on:

Situation Status Never
No session 401 (or redirect to /login in a page) 200 with an empty body
Signed in, role too low 403, or 404 when existence is secret Silently succeeding
Signed in, record belongs to someone else 404/403 — see chapter three Updating it anyway

Notice how the action above reports denials: it throws, and in production Next.js redacts the message so the client receives only an error digest — which protects internals but leaves the caller unable to tell "forbidden" from "the database is down." For denials the client should render, return a discriminated result instead of throwing — return { ok: false, code: "FORBIDDEN" } — and reserve thrown errors for genuinely unexpected failures. Explicit codes also make the client's job honest: it can show "you do not have access" without parsing prose.

Role alone is also not authorization. Two users both holding USER must not be able to edit each other's rows, so ownership has to travel with the query rather than being checked afterwards:

// IDOR: passes a role check, still crosses tenants
await prisma.post.update({ where: { id: postId }, data: { title } })

// scoped: the UPDATE matches zero rows unless the caller owns it
await prisma.post.updateMany({
  where: { id: postId, authorId: session.user.id },
  data: { title },
})

That second form is the fix for IDOR — insecure direct object reference — which is the single most common authorization finding in API reviews. The identifier comes from the URL, so treat every one of them as attacker-chosen: scope the statement, do not filter the result set in memory, because a post-filter still fetched the other tenant's data and paginates wrongly.

Two finishing moves. Keep the rules in one module (lib/authz.ts) so a change to "who may suspend users" is one edit instead of a grep across fourteen files — divergent copies are how /api/v2/admin ends up less protected than /admin. And log every denial with actor, action, and target: a burst of 403s from one account is an intrusion attempt, and you cannot alert on a check that fails silently. The identity feeding these checks was verified in Verifying JWTs on the Server; what remains is deciding how loudly to say no.

403 or 404? Hiding What a User Must Not Learn Exists

Both codes mean "denied," and choosing between them is a security decision, not a style one. Returning 404 instead of 403 can hide the existence of restricted resources, which matters when the mere existence of the resource is the secret.

The threat is enumeration. An attacker with a low-privilege session scripts /invoices/1001, /invoices/1002, … and reads the differences: 403 means the record exists and is worth stealing, 404 means nothing is there. One bit of information per request, harvested at thousands per second, maps your entire customer list without a single successful breach. The same logic covers other tenants' documents, unpublished drafts, and admin-only user ids.

Situation Return Why
Not signed in 401 or redirect to /login The user needs a way forward
Signed in, resource is secret (other tenant's invoice, admin panel) 404 Denies that the resource exists at all
Signed in, resource's existence is public (enterprise pricing page) 403 + a "request access" message 404 here is confusing, not protective
Signed in, record exists but belongs to someone else 404 (or 403, applied consistently) Ownership leaks existence too
Rate limited / locked out 429 Legitimate users must be able to tell the difference

Consistency is the part people miss. If /admin returns 404 for USER but 403 for SUPPORT, the comparison between two low-privilege accounts reveals which one is closer to the truth — so pick the rule per resource and apply it to every role below the threshold. And do not let timing disagree with the status: an early return notFound() while the authorized path loads ten rows means a stopwatch enumerates as reliably as a status code. Do comparable work in both branches, or accept that fast-fail timing leaks and account for it.

Equally important is the flip side: do not 404 your way out of authentication problems. A real user who is signed out and hits /dashboard should be sent to /login, not told the page does not exist — 404-hiding protects resources, and turning it into a general denial style hides your own bugs and destroys supportability. Keep the three signals distinct so logs stay readable:

const session = await auth()
if (!session) redirect("/login")            // unauthenticated: help them sign in

const project = await prisma.project.findUnique({ where: { id } })
if (!project || project.tenantId !== session.user.tenantId) notFound()
// wrong tenant: indistinguishable from a genuinely missing project

if (session.user.role !== "ADMIN") notFound()   // secret area: no hint it exists

Log every denial — actor id, attempted resource, 403 vs 404 — because the security team's dashboards read those numbers as reconnaissance signals, while a 200-with-an-error-field response reads as success.

The last habit that keeps this from regressing is testing the codes, not just the behavior. An integration test that asserts GET /invoices/42 returns 404 for the wrong tenant — and returns it with the same body and roughly the same latency as for a nonexistent id — pins the decision down; a refactor that swaps in notFound() for forbidden() and re-opens the enumeration hole fails that test instead of shipping. Assert the same status across methods too: GET saying 404 while DELETE answers 403 on one resource leaks the same information either way, because an attacker only needs one endpoint to disagree.

With roles, ownership, and status codes settled, the persistence layer these queries run against is the next thing to build: Connecting a Database picks up directly from the Prisma calls used throughout this topic, following Verifying JWTs on the Server.

Quiz

1. An admin page hides its buttons from non-admins, but the server action behind them never re-checks the role. What is the exposure?

Question 1 options

2. A signed-in USER requests /invoices/42, which belongs to another tenant. What should the handler return to reveal nothing about the record?

Question 2 options

3. Where must an authorization check live to be enforceable?

Question 3 options

4. Which statement about reading a role from a JWT claim is accurate?

Question 4 options

Flashcards

Question

Where does the role come from, and what does each source cost?

Answer

From the verified JWT claim: free, edge-compatible, but stale until the token expires or refreshes. From the users table after verification: one lookup, applies a demotion immediately. Use the claim for cheap cosmetic gates and a fresh read when the decision is expensive or must be immediate.

Question

Why re-check the role inside server actions and route handlers?

Answer

Hiding a control in the UI is not enforcement — server actions are POST endpoints with known ids and route handlers take attacker-chosen params. Both must authenticate and authorize themselves, because the client that called them is untrusted and the middleware may never have run.

Question

What is IDOR, and which query pattern prevents it?

Answer

Insecure direct object reference: acting on an id from the URL without proving ownership, so one user reaches another's rows while passing every role check. Fix it in the statement — updateMany({ where: { id, authorId: session.user.id } }) — so the database matches zero rows instead of filtering results in memory after the fetch.

Question

When do you return 404 instead of 403 for a denial?

Answer

When the existence of the resource is itself the secret — other tenants' records, unpublished drafts, admin-only ids. 403 confirms existence and lets enumeration scripts map your data one id at a time; 404 answers exactly as a missing record would. Use 403 when the resource is publicly known to exist and you just need a "request access" path.

Question

What are 401 and 429 for in an authorization design?

Answer

401 means nobody is signed in — a page redirects to /login instead, an API answers 401 so a client can re-authenticate. 429 means rate-limited, which must stay distinguishable so legitimate users and monitoring can tell throttling from denial; mixing it into auth failures hides both abuse and bugs.

Revision Notes

Key Takeaways

  • 1. Roles stored with the user record decide access, and a deny-by-default column means new sign-ups start with no privileges
  • 2. Authorization checks belong on the server — in the page, the action, and the handler — because the client cannot be trusted to enforce its own UI
  • 3. Role checks do not replace ownership scoping: query with the caller's id in the WHERE clause or you ship an IDOR
  • 4. Returning 404 instead of 403 hides the existence of restricted resources from enumeration scripts, but only when you apply the rule consistently across roles
  • 5. A single requireRole() helper keeps fourteen routes from drifting into four different interpretations of the same policy

Interview Tips

  • • "How do you protect an admin page in Next.js?" — await auth() first, then a fresh role read, then notFound() or redirect(); mention that layouts do not re-run on every navigation, so the page carries its own guard
  • • "Your button is hidden but the API still allows it — what is the principle?" — client checks are UX; every server entry point re-authorizes. Naming server actions as POST endpoints with generated ids shows you know why hiding is not enforcement
  • • "403 or 404 for another tenant's record?" — 404, because 403 confirms existence and turns id-guessing into a mapping tool; then add the consistency rule (same response for every denied role) and the timing caveat
  • • "How would you design roles for a multi-tenant app?" — role plus tenant scoping in the same query, ownership in the WHERE clause rather than post-filtering, deny-by-default defaults, and denial logging for the security team

Cheat Sheet

Role-Based Access Control

Model (deny by default):

enum Role { USER SUPPORT ADMIN }
model User {
  id    String @id @default(cuid())
  email String @unique
  role  Role   @default(USER)
}

Guard the render:

const session = await auth()
if (!session) redirect("/login")                 // unauthenticated
const user = await prisma.user.findUnique({
  where: { id: session.user.id }, select: { role: true },
})
if (user?.role !== "ADMIN") notFound()           // denied: hide existence

Guard the mutation (server actions and route handlers are HTTP endpoints):

if (!session) return Response.json({ error: "unauthenticated" }, { status: 401 })
if (session.user.role !== "ADMIN") return Response.json({ error: "forbidden" }, { status: 403 })
await prisma.post.updateMany({ where: { id, authorId: session.user.id }, data })

Status codes:

Situation Return
Not signed in 401 / redirect to /login
Denied, resource is secret 404 (no enumeration)
Denied, resource publicly known 403 + request-access path
Wrong owner 404 or 403, applied consistently
Throttled 429

Rules: checks on the server · scope statements by ownership (no post-filter) · one requireRole() helper so copies cannot drift · log every denial with actor and target.

Continue Learning

Before You Start

Next Topics

Related Reading