DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our admin panel answers 404 to every signed-in account that is not on a hardcoded list

Nakodo has an operator panel at /admin: six pages that read the whole service. Accounts, plans, campaign counts, support threads, inbound mail, the background run log. It is the one surface in the product that can see other people's data, which is exactly the data the privacy notice promises to keep to a short list of uses.

So the interesting question is not what the panel shows. It is how it decides who you are, and what it does when the answer is no.

Admin is a constant, not a column

export const ADMIN_EMAILS: readonly string[] = [OPERATOR_EMAIL];

export function isAdminEmail(email: string | null | undefined): boolean {
  return !!email && ADMIN_EMAILS.includes(email.trim().toLowerCase());
}
Enter fullscreen mode Exit fullscreen mode

No is_admin column. No ADMIN_EMAILS environment variable. The list is in the source, and the reasoning is written next to it:

It cannot be escalated from inside the product. There is no row to flip, so a bug in a Server Action or a compromised account grants nothing unless the account's address is also in a deployed commit.

It has no bootstrap problem. There is no migration or seed script that has to mark the first operator before the panel works at all.

It cannot go missing in a deploy. An allowlist read from the environment is empty wherever nobody set the variable, and an empty allowlist 404s at its own operator with nothing in the logs explaining why. I would rather the failure mode be a code change than a silent lockout.

Adding an operator is a commit and a deploy. For the one list that grants access to every customer record, that is the intended cost, not an inconvenience to be engineered away.

The test for it is three assertions long, and the first one is the only one that matters:

test("the allowlist has exactly one operator", () => {
  assert.deepEqual([...ADMIN_EMAILS], [/* the single address */]);
});

test("the comparison ignores case and surrounding space", () => { /* … */ });

test("nobody else is an admin, and neither is a missing address", () => {
  assert.equal(isAdminEmail("…@….com.evil.test"), false);
  assert.equal(isAdminEmail(""), false);
  assert.equal(isAdminEmail(null), false);
});
Enter fullscreen mode Exit fullscreen mode

Pinning the exact contents of the array in a test looks pointless until you imagine the diff that appends a second address to it. That diff cannot land quietly.

The .evil.test case is there because the obvious wrong implementation is .includes() on the string rather than on the array, or an endsWith check, and both of those happily admit operator@example.com.evil.test. Exact comparison against a lowercased, trimmed address is the whole check.

The refusal is 404, not 403

export const requireAdmin = cache(async (returnTo = "/admin"): Promise<SessionUser> => {
  const user = await getUser();
  if (!user) redirect(`/login?next=${encodeURIComponent(returnTo)}`);
  if (!isAdminEmail(user.email)) notFound();
  return user;
});
Enter fullscreen mode Exit fullscreen mode

A signed-out visitor is sent to sign in and brought back afterwards, because that visitor might be the operator. A signed-in account that is not on the list gets notFound().

403 says "this exists and you are not allowed in", which confirms the panel to anybody who has made a free account and typed a URL. 404 says nothing. The panel does not acknowledge its own existence to anyone who merely has an account. The metadata backs that up on the few paths that could be fetched anyway:

export const metadata = {
  title: { template: "%s · Admin", default: "Admin" },
  robots: { index: false, follow: false, nocache: true },
};
export const dynamic = "force-dynamic";
Enter fullscreen mode Exit fullscreen mode

force-dynamic is not a performance note. The gate is a per-request decision, so nothing under /admin may ever be statically rendered and handed to the next person who asks.

The cache() wrapper is React's request-scoped cache, so the layout, a page and whatever else calls it inside one request share a single session lookup instead of three.

Thirteen call sites for one check

The gate is called 13 times across 8 files: once in the layout, once in three of the pages, once in the CSV export route, and once at the top of every one of the eight Server Actions.

The layout call does not cover the rest, and the comment in the actions file says why:

// Operator actions on somebody else's account. Every one of them re-checks
// the allowlist itself: a Server Action is a POST endpoint, and the gate in
// the layout above it doesn't run when one is called directly.
Enter fullscreen mode Exit fullscreen mode

That is the part worth repeating to anyone building an admin area in the App Router. A Server Action is not a function call that happens to run on the server. It is a POST endpoint with a generated id, reachable without ever rendering the layout that supposedly protects it. Layout-level auth protects the view. It does not protect the verbs.

Same for the route handler that hands out the mailing list as a CSV:

export async function GET(request: NextRequest) {
  await requireAdmin("/admin/mailing");
  // …
  headers: { "cache-control": "no-store" }
}
Enter fullscreen mode Exit fullscreen mode

It carries customer email addresses, so it is gated, never cached, and served from the panel's own origin.

Operator actions leave fingerprints in the customer's own record

The destructive action is account deletion, and two things guard it. The operator has to type the account's own email address, because the realistic mistake is pasting the wrong user id out of a support thread:

if (!typed || typed !== (account.email ?? "").toLowerCase()) {
  return fail("Type the account's own email address exactly to confirm.");
}
Enter fullscreen mode Exit fullscreen mode

And Stripe is cleaned up before the row, because a deleted account with a live subscription keeps billing somebody who no longer exists to us. If Stripe refuses, nothing is deleted and the operator is told so.

Every operator change is written to the audit log against the account it touched, with who made it:

await audit({ action: "admin.account_deleted", userId, entityType: "user", entityId: userId, metadata: { by: operator.email } });
Enter fullscreen mode Exit fullscreen mode

Which means an operator editing somebody's record shows up in that person's own activity, not in a separate admin-only log that only the operator can read. For a product that emails people on a customer's behalf, described end to end on how it works, that asymmetry was the one I did not want to ship.

None of this is clever. It is a constant, a 404, and the discipline of calling the same function 13 times instead of assuming a layout did it for you.

Top comments (0)