Loading…
Loading…
Putting the permission check in the dashboard layout feels natural. Next does not re-render layouts on client navigation, and a crafted RSC request can target a page segment directly.

This site's dashboard has a layout wrapping every admin page. Putting the permission check there is the obvious move: write it once, protect everything underneath.
Except it does not protect anything.
First, Next does not re-render layouts on client-side navigation. That is the entire point of a layout: the frame persists and only the content swaps. A check living there runs on the first page load and never again.
Second, and worse: a crafted RSC request can target a page segment directly. An attacker does not have to walk through your interface. They send a request straight at the segment they want, and the layout is not on that path.
So every dashboard page re-checks for itself:
export default async function EditPostPage({ params }) {
await requireAdminPage();
const { id } = await params;
// …
}
The cost is close to zero, because the session is already sitting in cookies. In exchange the guarantee lives in the file that actually reads the data, rather than in a parent you have to remember exists.
export async function requireAdminPage() {
let allowed = true;
try {
await verifyAdmin();
} catch {
allowed = false;
}
// OUTSIDE the catch: redirect() signals by throwing.
if (!allowed) redirect("/login?error=forbidden");
}
That comment is the most expensive line in the function. Next's redirect() works by throwing a special exception the framework catches higher up. Write it inside the catch block and that block swallows the redirect signal, and the function returns normally.
The result: every bounce becomes a silent pass. No log, no error, the page simply renders for somebody who should never have seen it.
What makes this class of bug nasty is that it tests clean. Sign in as an admin and everything behaves. The broken path only runs for people who are not supposed to be there, which is exactly the population absent from your test run. A bare catch around a block containing framework control flow deserves a second look every time, and the habit worth building is to keep the throwing call outside the block that swallows exceptions.
The admin list is a comma-separated server-side environment variable. It never gets a public prefix, because that prefix inlines the value into the JavaScript bundle shipped to browsers. Your list of admin email addresses would sit in a file anyone can download.
verifyAdmin reads the session from cookies, validates it against Supabase Auth, then checks the email:
export async function verifyAdmin() {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) throw new Error("Unauthorized: Invalid session");
if (!isAdminEmail(user.email)) throw new Error("Forbidden: Admin access required");
return user;
}
Two distinct errors, not one. "Not signed in" and "signed in without sufficient rights" are different situations, and collapsing them costs you the ability to send the person somewhere useful.
The codebase has a component wrapping the dashboard that hides content when the user is not an admin. It is useful: somebody who wandered in sees a login screen instead of a broken table.
It is not security. That is code running inside the reader's browser, and readers have complete authority over their own browsers. It decides what gets drawn, not what the server agrees to answer.
The real boundary sits where data is read. This site has a Supabase client using the service role, which bypasses Row Level Security entirely. That file opens with import "server-only", so anyone who accidentally imports it into a client component breaks the build right then, rather than discovering the key in a production bundle later.
The shortest rule I have found: every use of the service role needs a visible verifyAdmin in the same file. Not in a parent layout, not in middleware, not in a wrapper component. The same file.
No comments yet — be the first!