Munchable is a food app with one person behind it. You scan a product, you get a straight answer about whether it suits your gut, and the public half of it is 516 URLs you can read without an account: the conditions pages, the answer pages, the recipes. You can count them yourself in the sitemap.
None of those 516 URLs is under /admin. That is the other half: six pages and seven API routes where customer records, support tickets and unpublished catalog rows live. This post is about the gate in front of them, which is 170 lines in one file and whose most important line is a string literal.
The allowlist is a constant, not a column
/**
* The emails allowed into the admin dashboard and every admin API.
*
* Written in lower case, because every comparison goes through
* `normalizeEmail()`, and the entries are the right-hand side of that check.
*/
const ADMIN_EMAILS: readonly string[] = ['...@gmail.com'];
One entry. (Redacted here, not in the repo; it is my own address.) No is_admin boolean, no role column, no ADMIN_EMAILS environment variable. The file says why:
* - It cannot be escalated from inside the product. There is no row to flip,
* so a bug, a leaked service key or a compromised account grants nothing
* unless that account's email is also in this file.
* - It has no bootstrap problem. The operator does not need a migration or a
* one-off script to mark their own account admin before the panel works.
* - It cannot go missing in a deploy. 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.
The third one is the one I learned rather than reasoned my way to. An allowlist in the environment has a failure mode that looks exactly like a bug in the panel: the variable is unset, the list is empty, nobody is an admin, and the page you wrote answers 404 to you. Nothing is logged, because from the gate's point of view nothing went wrong.
The cost of the constant is that 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, and at one operator it has never been paid.
There is a line under the comment that matters as much:
// Anonymous Supabase sessions are never admin, whatever email they claim: an
// anonymous id is free to mint and carries no verified address.
The app signs people in anonymously so a scan works before anyone has typed an email. Those sessions are real sessions with real ids, and an email on one is not an email anybody proved. The gate rejects isAnonymous before it ever reaches the string comparison.
Two rejections, two different answers
Most gates have one outcome. This one has three states, and the shape is the point:
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' };
A signed-out visitor is redirected to sign in. A signed-in non-operator gets notFound(). You can watch both from a terminal. Signed out:
$ curl -s -i https://munchable.app/admin | grep -i '^HTTP\|^location'
HTTP/2 307
location: /get-started?next=%2Fadmin
That lands you on the real sign-in page, with the page you wanted carried in next=. The first version 404'd that case too, and the comment explaining why it stopped is the most useful paragraph in the file:
* Why not a 404 for everyone, as the first version did (and as CogniPrep still
* does)? 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.
Three debugging sessions, all of them the same answer, all of them caused by a browser that had no session on that origin. The 404 was defending against an attacker who learns that a site has an /admin, which every attacker already assumes. It was costing the one legitimate user the only clue available.
The asymmetry is the whole design: tell the signed-out case something, because it is almost always me, and tell the signed-in non-operator nothing, because the allowlist is the boundary and a 404 means they learn nothing from probing.
The layout gates, and so does every page under it
Seven files call the page gate: the layout, and all six pages beneath it. That looks like exactly the redundancy a layout is supposed to remove, and it is not:
* The layout gates the subtree too, and that is not redundant. A layout's
* `redirect()`/`notFound()` decides the status, but Next has already begun
* rendering the page beneath it: an async page that is mid-`await` keeps 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.
A layout and its page render concurrently, so a layout's redirect() does not cancel the page's await. Without the second check the right status still goes out, eventually, after a query nobody was entitled to run has finished running.
The second check is free, because the resolver is wrapped in React's cache():
const resolveDashboardAdmin = cache(async function resolveDashboardAdmin(): Promise<AdminGate> {
One resolution per request, shared by the layout and the page. Seven call sites, one evaluation.
404, not 403, on the API side
The seven route files behind the panel export eleven handlers, and all of them share one wrapper, so the gate is written once:
// A non-admin gets 404, not 403, the same answer a nonexistent route gives, so
// probing /api/admin/* cannot be used to discover which endpoints exist.
const admin = await requireApiAdmin(request);
if (!admin) return notFound();
Try it:
$ curl -s -i https://munchable.app/api/admin/users | head -4
HTTP/2 404
cache-control: no-store
content-type: application/json
Writing this post, I checked the claim in that comment against a route that genuinely does not exist, and it is not quite true:
$ curl -s https://munchable.app/api/admin/does-not-exist | head -c 40
<!DOCTYPE html><html data-dpl-id="dpl_7tq
A missing route falls through to the branded 404 page and answers HTML. A real admin route answers {"error":"not_found"} as JSON. The status matches, the body does not, so somebody who reads the body can still tell which admin routes exist. I am leaving it, and writing it down rather than quietly fixing it, because the comment oversells a defence that was never the boundary: knowing that /api/admin/users exists gets you a 404 from it. The allowlist is what stops you, and route existence is guessable from the panel's own URLs anyway.
The Cache-Control: no-store in that response is not decoration. Admin responses carry customer email addresses, and the one thing worse than serving them to the wrong person is letting a CDN keep a copy.
The limiter that is deliberately absent, and the one that fails open
// Reads are NOT rate limited. There is one legitimate caller and the allowlist
// is the security boundary, so the only thing a limiter could add is a Redis
// round trip on every click of the panel. Routes that put real email in real
// inboxes opt into the tight 'send' budget, and that one FAILS OPEN: locking
// the operator out because Redis blinked would be worse than the double send
// it guards against.
A rate limit on a panel with one user is a latency tax with a story attached. The routes that send mail do opt in, because a double-clicked Send is a real risk, and that limiter is wrapped in a try whose catch is empty on purpose. The threat model has one person in it, and "Redis is down and now nobody can answer a support ticket" is a worse Tuesday than two copies of one email.
What the file does not do
No audit log of admin actions (the actions themselves are logged elsewhere, by the things they touch). No second factor beyond the email sign-in. No session pinning. Those are all reasonable for a product with a team, and all of them would be ceremony for a product whose admin list cannot grow without a deploy.
The dev-only rejection explainer is the last thing in the file, and it draws one line carefully:
* Explains a rejection in development only: whether it was "not signed in" or
* "other address". Production stays silent. The allowlist is printed because it
* is a constant in this file; a session email never is.
It prints the allowlist, because the allowlist is already in the source. It prints cookie names, because sb-access-token existing is not a credential. It never prints the session's own email or token value. The whole purpose of the log is to answer "did any auth cookie arrive at all", which is the question that ended all three of those investigations.
The public side of all this is the part you can actually look at: the conditions pages, the hidden gluten reference, the recipes. The panel behind them is six pages, eleven handlers and one string.
Top comments (0)