forbidden()

Throws a 403 interrupt that renders the nearest forbidden.js boundary for authorization failures.

Signature
forbidden(): never
  • Added in Next.js v15.1.0

Return value

neverDoes not return a value. It throws a NEXT_HTTP_ERROR_FALLBACK;403 error and terminates rendering of the route segment where it was called.

Errors

Always throws NEXT_HTTP_ERROR_FALLBACK;403 by design; it also throws if the experimental authInterrupts flag is not enabled, and it cannot be called in the root layout.

Changelog

  • PHP v15.1.0forbidden introduced behind the experimental authInterrupts flag.

forbidden() covers the authenticated-but-not-allowed case: the visitor is signed in, their identity is known, and they still may not see this. That is the 403 half of the pair — unauthorized() handles the 401 half, where there is no session at all.

It is experimental. Add experimental.authInterrupts: true to next.config.ts or the call throws a configuration error instead of a 403.

Invoking it throws NEXT_HTTP_ERROR_FALLBACK;403, unwinds the segment, renders the nearest forbidden.js, and injects <meta name="robots" content="noindex" />. It cannot be called from the root layout — there is no boundary above it to catch the throw.

Where to put the check

The natural home is the Data Access Layer function that loads the protected data, so the rule lives next to the query rather than being repeated in every component. Awaiting that function from a component inside <Suspense> keeps the shell visible while the session resolves — at the cost of the status code, since headers are already out as 200 once streaming begins. Check in proxy if you need a real 403.

Example

Example · typescript
import { forbidden } from 'next/navigation'
import { verifySession } from '@/app/lib/dal'

export default async function AdminPage() {
  const session = await verifySession()

  if (session.role !== 'admin') {
    forbidden()
  }

  return (
    <main>
      <h1>Admin Dashboard</h1>
      <p>Welcome, {session.user.name}.</p>
    </main>
  )
}

When to use it

  • Block a signed-in user without the admin role from /admin
  • Reject a role-gated mutation inside a Server Function before it touches the database
  • Deny a member of the wrong organisation access to another tenant's project
  • Return 403 from a Route Handler when the session lacks the required scope

Frequently asked questions

Why does forbidden() throw a configuration error?

The experimental authInterrupts flag is not enabled. Add experimental: { authInterrupts: true } to next.config.ts and restart the dev server; both forbidden() and unauthorized() depend on it.

When should I use forbidden instead of unauthorized in Next.js?

Use forbidden() when the user is signed in but lacks permission (403), and unauthorized() when there is no valid session at all (401). Sending a signed-in user to a login screen because you picked the wrong one is the usual symptom of getting it backwards.

Can I call forbidden() in the root layout?

No. The root layout has no boundary above it to catch the thrown interrupt, so the call is not allowed there. Move the check into a nested layout, the page, or the data-access function that loads the protected data.

More examples

Enabling the experimental flag

Without this flag both forbidden() and unauthorized() are unavailable and the call fails with a configuration error rather than rendering your 403 UI. The flag is experimental and the API may change, so it is not recommended for production.

Example · typescript
// next.config.ts
import type { NextConfig } from 'next'

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

export default nextConfig

Checking in the Data Access Layer behind Suspense

The heading and fallback stream out first, then getProjects throws and the forbidden boundary swaps in for the list. The rule sits with the query, so any component that awaits getProjects inherits it. The visible trade-off is the network response: it went out as 200 before the check ran.

Example · typescript
// app/projects/page.tsx
import { Suspense } from 'react'
import { forbidden } from 'next/navigation'
import { verifySession } from '@/app/lib/dal'
import { db } from '@/app/lib/db'

async function getProjects() {
  const session = await verifySession()

  if (session?.role !== 'admin') {
    forbidden()
  }

  return db.projects.findMany()
}

async function Projects() {
  const projects = await getProjects()
  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  )
}

export default function ProjectsPage() {
  return (
    <main>
      <h1>Projects</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <Projects />
      </Suspense>
    </main>
  )
}

Guarding a role-restricted mutation

A Server Action is a public POST endpoint, so hiding the form in the UI is not a boundary — the check has to run inside the action itself. forbidden() throws before the update line is reached, so the mutation never executes for a non-admin caller.

Example · typescript
// app/actions/update-role.ts
'use server'

import { forbidden } from 'next/navigation'
import { verifySession } from '@/app/lib/dal'
import db from '@/app/lib/db'

export async function updateRole(formData: FormData) {
  const session = await verifySession()

  if (session.role !== 'admin') {
    forbidden()
  }

  await db.user.update({
    where: { id: String(formData.get('userId')) },
    data: { role: String(formData.get('role')) },
  })
}

Discussion

  • Be the first to comment on this lesson.