Munchable has an admin panel. It is where the catalogue, the taxonomy, the support tickets and every user record are visible, so it is the single most valuable door in the product. It is also the one I expected to need the least code, and it turned into the most carefully commented file in the repository.
The answer to "who is an admin" is a hardcoded array:
const ADMIN_EMAILS: readonly string[] = ['<the operator>'];
Not a role column. Not an environment variable. A constant in a TypeScript file that ships compiled into the deployment. Here is the case for that, including the mistake that made the case.
What an allowlist in the source cannot do
It cannot be escalated from inside the product. There is no row to flip. A SQL injection, a leaked service key, a compromised account, a bug in an update path: none of them grant admin, because granting admin is not a database operation. The only path is a pull request.
It has no bootstrap problem. The usual role-column design needs the first admin to exist before the panel that creates admins works, which means a migration or a one-off script against production, which means the most privileged operation in the system is also the one with the least tooling around it.
It cannot go missing in a deploy. This is the one I learned the hard way, and it is the whole reason the comment in that file is twenty lines long:
An allowlist read from the environment is empty wherever nobody remembered to set the variable, and an empty allowlist makes the panel 404 at its own operator with nothing in the production logs to say why. That is what the first production deploy did.
An environment variable is strictly worse than a constant here on every axis I care about. It is not more secret: the list is email addresses, and the deployment bundle is not public either way. It is not more flexible, because the people on it change roughly never. And it has a failure mode a constant does not have: absent. A missing secret usually breaks loudly. A missing allowlist breaks by politely telling the only person who can fix it that the page does not exist.
The cost is honest and small: adding an operator is a code change and a deploy. For the one list that grants access to every customer record, that is the intended price.
Three outcomes, not two
The gate resolves a browser session into one of three states:
export type AdminGate =
| { status: 'admin'; admin: AdminIdentity }
/** No usable session on this origin: signed out, or only an anonymous session. */
| { status: 'signed_out' }
/** A real account that is not the operator. */
| { status: 'forbidden' };
And the pages treat the last two differently: signed out is redirected to sign in and brought back, a signed-in stranger gets a 404.
The first version returned 404 for both, which is the textbook answer and which I now think is wrong for a one-operator panel:
Why not a 404 for everyone, as the first version did? Because to the operator a silent 404 is indistinguishable from the panel being broken. Every "admin doesn't load" investigation so far ended at "the browser had no session on that origin", and a page that says nothing cannot tell them that.
The leak from redirecting is that /admin wants a sign in, which any stranger would assume of any site's /admin anyway. The allowlist is the security boundary, and a signed-in non-operator still learns nothing at all.
You can run the test yourself:
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://munchable.app/admin
307 https://munchable.app/get-started?next=%2Fadmin
Sign in with any account and the same URL answers 404 instead, which is the interesting half of the behaviour and the half a stranger cannot see.
An anonymous Supabase session is never admin, whatever email it claims, because an anonymous id is free to mint and carries no verified address. That check sits above the allowlist comparison, not inside it.
The gate runs twice, and that is not redundancy
Both the admin layout and every admin page call the gate. That looked like belt and braces until the first time a non-admin request hung instead of returning:
A layout's
redirect()/notFound()decides the status, but Next has already begun rendering the page beneath it: an async page that is mid-awaitkeeps the response stream open, so the request hangs instead of completing. Checking here as well means a non-admin is turned away BEFORE a single query starts.
So the rule in this codebase is that an admin page's first statement, before it awaits anything, is the gate:
export async function requireAdminPageAccess(returnTo = '/admin'): Promise<AdminIdentity> {
const gate = await resolveDashboardAdmin();
if (gate.status === 'admin') return gate.admin;
if (gate.status === 'signed_out') redirect(adminSignInPath(returnTo));
notFound();
}
Calling it twice per request is free, because the resolver is wrapped in React's cache(), so the layout and the page it wraps share one result. The session itself is verified locally from its claims rather than with a round trip to the auth service, so the whole check is work the request was doing anyway.
The API side is the same allowlist with a different shape, accepting either a bearer token or session cookies:
export async function requireApiAdmin(request: NextRequest): Promise<AdminIdentity | null> {
const user = await getUser(request);
if (!user || user.isAnonymous) return null;
if (!isAllowedAdminEmail(user.email)) return null;
return { id: user.id, email: user.email ?? '' };
}
One predicate, used by the dashboard and by every /api/admin/* route, so there is no second definition of "admin" to keep in step. The other allowlist in the codebase, the one that decides which routes need a session at all, lives in the proxy and I wrote about it separately in Next 16 renamed middleware to proxy, and our whole auth boundary is one allowlist.
One deliberate hole, in development only
Rejections are explained on a dev machine and silent in production:
async function explainRejection(reason: string, detail?: string): Promise<void> {
if (process.env.NODE_ENV === 'production') return;
// ... names of any sb-* cookies, and the allowlist
}
It prints which sb-* auth cookies arrived, because that separates "not signed in" from "signed in but unreadable on this origin", and the two look identical from the browser. It prints the allowlist, which is safe: the allowlist is a constant in the file you are reading. It never prints the session's email or a cookie value, because a session cookie is a bearer credential and a log is not a safe place for one.
That asymmetry is the design in miniature. The constant is the part you are allowed to see, the session is the part nobody gets to see, and the only reason the panel is hard to lock yourself out of is that the list lives where the code review is.
Everything the panel administers is visible from the public side too, if you want the context: the curated answer pages at munchable.app/answers, the condition guides at munchable.app/conditions, and the app itself at app.munchable.app.
Top comments (0)