THE FULL GUIDE / EP 05 · ASTER × GEMINI
Your admin vault is guarded by display: none
We hired a crew to rob our own app. It took four seconds: the admin button was hidden, but the API never checked who was calling. Here are the three locks that stop it.
COMMENTED ADMIN? HERE’S THE FIX.
The fix, in three moves.
- Check the role on the server in every admin route and Server Action, not just the page.
- Take the role from the session or database, never
localStorage, the request body oruser_metadata. - Deny by default, then prove it: a normal user’s token must get
403.
FOR YOUR CODING AGENT
Copy this prompt to your agent to investigate this issue.
- Claude
- ChatGPT
- Gemini
- Cursor
- Copilot
You are a security reviewer for this codebase. Investigate ONE issue: admin-only actions that are protected only in the frontend (OWASP A01 Broken Access Control).Where to look:- Every privileged route: `app/api/**/route.ts`, Server Actions (`'use server'`), `pages/api/**`, Express routers, edge or serverless functions, Supabase RLS policies and migrations.- Search for `admin`, `isAdmin`, `role`, `requireAdmin`, `localStorage`, `user_metadata`, `jwtDecode`, `req.body.role`, `middleware.ts`, `proxy.ts`.How to judge each admin route or action:1. Does that handler check the role on the server, on every call? A hidden button, a client redirect, a layout check or Proxy/middleware alone don't count.2. Where does the role come from? Session or database = OK. `localStorage`, the request body, a plain cookie, a decoded-but-unverified JWT or Supabase `user_metadata` = critical.3. Can a normal user change their own `role` (an editable column, or `req.body` spread into the user row)?4. Does it fail closed: `401` with no session, `403` with the wrong role, and nothing else happens?Report a table: file:line | what's wrong | severity (critical, high, medium, low) | the minimal fix (code). Then list the admin routes that are already safe.Rules: read and report only. Don't modify any file until I approve. Never print secrets or tokens: mask them.Then ask me which fixes to apply.Works with Claude Code, Cursor, Codex, Gemini CLI, Copilot… It only reads and reports; it won’t change your code until you approve.

Jump to a story
- The fix
- Agent prompt
- 01What “protected only in the frontend” looks like
- 02Hiding the button isn’t security
- 03How anyone calls your admin API directly
- 04The role trap: localStorage, JWTs and request bodies
- 05Number one on the OWASP Top 10, twice
- 06Check the role on the server, in every admin route (Next.js App Router)
- 07Express: guard the API router, not the page
- 08Take the role from the session or database (Supabase RLS)
- 09Deny by default, then test with a normal user’s token
- 10Log every denied admin call (and every admin action)
- 11Rob your own app before someone else does
- FAQ
- Source notebook
What “protected only in the frontend” looks like

display: none, so they just walk in.Most apps have an admin area: ban a user, refund an order, change a plan, read every account. The usual first version, and the one an AI assistant happily writes when you ask for “an admin page only I can see”, protects it in the browser: the admin link only renders for you, and the admin page redirects everyone else.
That is a vault guarded by display: none: the lasers are only hidden. The vault is your API, and in this version it never asks who is calling:
// app/api/admin/ban/route.ts
export async function POST(req: Request) {
const { id } = await req.json()
// Nobody asks who is calling.
await db.user.update({ where: { id }, data: { banned: true } })
return Response.json({ ok: true })
}// app/api/admin/ban/route.ts
import { requireAdmin } from '@/app/lib/dal'
export async function POST(req: Request) {
const admin = await requireAdmin() // server-side, every call
if (!admin.ok) return new Response(null, { status: admin.status })
const { id } = await req.json() // the body says WHAT, never WHO
await db.user.update({ where: { id }, data: { banned: true } })
return Response.json({ ok: true })
}On the left, the page might be perfectly hidden. It doesn’t matter: the route bans whoever is in the body, for whoever sends the request. On the right, the route asks the server-side session who is calling and checks the role before it touches the database. That one check is the lock.
The page that “only I can see”
// app/admin/page.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function AdminPage() {
const router = useRouter()
useEffect(() => {
// "Only I can see this page."
if (localStorage.getItem('isAdmin') !== 'true') router.replace('/')
}, [router])
const ban = (id: string) =>
fetch('/api/admin/ban', { method: 'POST', body: JSON.stringify({ id }) })
return <UserTable onBan={ban} />
}Everything here runs on the visitor’s computer: the localStorage read, the redirect, and the fetch to the admin route. They can read it, change it, or skip it.
THE BUILDER’S TAKEAWAYA redirect tells honest people where not to go. It doesn’t stop anyone else.
Hiding the button isn’t security

Every “hide it” technique changes what the page shows. None of them changes what the server does when a request arrives:
{isAdmin && <AdminLink />}hides a link. The URL still works, and the route behind it still answers.- A
useEffectredirect runs after your JavaScript loads, in a browser the visitor controls. They can stop it, or never load the page at all. - An obscure path like
/super-secret-admin-x7is not a password. Paths ship in your JavaScript bundle, show up in the Network tab, and get guessed. OWASP calls guessing them force browsing. - A layout that hides its children. Next.js warns that layouts don’t re-render on navigation, and that a layout hiding a segment doesn’t stop that segment, or its Server Actions, from running.
- Proxy (middleware) redirects. Useful, but they run before the page, and an API route or Server Action can be outside the matcher. More on this below.
The rule underneath all of it: the frontend decides what to show; the server decides what to allow. If the server doesn’t check, nothing does.
THE BUILDER’S TAKEAWAYA hidden button is a design decision. A role check is a security decision.
How anyone calls your admin API directly

OWASP’s own example scenario for broken access control is the Courier’s whole job in this episode: an application puts all of its access control in the front end, and while the attacker can’t reach the admin page because of JavaScript in the browser, they can simply call the admin URL with curl from the command line.
Nobody needs to be a hacker for this. Every browser ships the tools:
- Open DevTools → Network, use the app normally, and watch the requests. Admin-looking routes appear the moment any admin code runs, or they’re sitting in the JavaScript bundle.
- Right-click a request → Copy as fetch or Copy as cURL. Your session cookie comes along with it.
- Change the method, the ID or the body, and send it again. From the console, from a terminal, from a script that does it 10,000 times.
Try it on your own app
// DevTools console, on YOUR app, signed in as a normal user:
localStorage.setItem('isAdmin', 'true') // the "hidden" admin page now renders
// …and the API never needed the page in the first place:
await fetch('/api/admin/users').then((r) => r.status)
// 200 = open. 403 = locked.# 1. Sign in to your app as a NORMAL test user.
# DevTools → Application → Cookies → copy the session cookie.
# 2. Call an admin route yourself, against a user you own:
curl -i -X POST https://your-app.com/api/admin/ban \
-H "Cookie: session=PASTE_NORMAL_USER_COOKIE" \
-H "Content-Type: application/json" \
-d '{"id":"YOUR_OTHER_TEST_USER_ID"}'
# Locked: HTTP/1.1 403 (and nobody is banned)
# Open: HTTP/1.1 200 (a normal user just banned someone)Only test apps you own, with test accounts you can undo. If a normal user’s cookie gets anything other than 403 (or 401 with no cookie), the door is open.
| What you see | What it means | The fix |
|---|---|---|
| The admin link only renders for you | The UI is hidden; the route still answers | Check the role in the route handler |
/admin redirects in useEffect | The page is hidden, not the data | requireAdmin() in every route and action |
isAdmin in localStorage | The user sets their own role | Role from the session or database |
role in the request body | The caller picks their own role | Ignore it; look the caller up |
jwtDecode() on the server | Decoding isn’t verifying | Verify the signature (e.g. jwtVerify) |
Supabase policy reads user_metadata | Users can edit user_metadata | Roles table or app_metadata |
Profile update spreads req.body | A user can send "role":"admin" | Allowlist the editable fields |
| A new admin route with no test | Open until someone notices | Deny by default + a 403 test |
THE BUILDER’S TAKEAWAYIf the browser can call it, so can curl.
The role trap: localStorage, JWTs and request bodies

isAdmin: true. Never misses. If the browser says who the admin is, he’s the admin.Some apps do check on the server, and still lose, because they ask the wrong source. A role check is only as good as where the role comes from. If the caller can write it, the caller can be admin.
// ❌ The person making the request wrote every one of these.
localStorage.getItem('isAdmin') === 'true' // browser storage: edit in DevTools
(await req.json()).role === 'admin' // request body: anyone can send one
req.cookies.get('role')?.value === 'admin' // plain cookie: just a header
jwtDecode(token).role === 'admin' // decoded, never verified
user.user_metadata.role === 'admin' // Supabase: updateUser({ data })// ✅ The server looks it up itself, on every request.
const session = await decrypt(cookie) // jose jwtVerify: signature + expiry
if (!session?.userId) return deny(401)
const user = await db.user.findUnique({
where: { id: session.userId },
select: { id: true, role: true },
})
if (user?.role !== 'admin') return deny(403) // stored in YOUR databaselocalStorageandsessionStoragebelong to the user. Anyone can typelocalStorage.setItem('isAdmin', 'true')in the console. Use them for UI preferences, never permissions.- The request body is whatever the caller sends. A
role,isAdminoruserIdfield in it says who they claim to be, not who they are. - A plain cookie like
role=adminis a header the user can edit. A signed session cookie is different: the server verifies its signature and expiry before trusting anything in it. - A JWT you only decode.
jwtDecode()reads the payload without checking the signature, so an edited token decodes just fine. On the server, verify it (for example jose’sjwtVerify, as the Next.js docs do). Even then, a role baked into a token is stale until the token is refreshed, so look it up when it matters. - A user-editable profile field. If users can update their own profile row and the
rolecolumn lives there, they can update the role too.
Mass assignment: the profile update that promotes
// ❌ "Update my profile", which also updates… role
const body = await req.json() // {"name":"Ann","role":"admin"}
await db.user.update({ where: { id: me.id }, data: body })
// ✅ Copy only the fields a user is allowed to change
const { name, avatarUrl } = await req.json()
await db.user.update({ where: { id: me.id }, data: { name, avatarUrl } })THE BUILDER’S TAKEAWAYChecking the role on the server is half the fix. Reading it from somewhere the user can’t write is the other half.
Number one on the OWASP Top 10, twice
Broken access control is the most common serious web app flaw in OWASP’s data. In the 2021 list it moved up from fifth place to #1; in the 2025 list it stays there, and OWASP writes that 100% of the applications tested were found to have some form of broken access control, with 1,839,701 occurrences across the contributed data. In 2025 the category also absorbed server-side request forgery.
The bullets OWASP lists under it read like a description of this episode:
- Bypassing access control checks by modifying the URL, internal application state or the HTML page, or by using an attack tool that modifies API requests.
- An accessible API with missing access controls for
POST,PUTandDELETE. - Elevation of privilege: acting as a user without being logged in, or gaining privileges beyond those expected of the logged-in user, such as admin access.
- Metadata manipulation, such as replaying or tampering with a JWT, a cookie or a hidden field to elevate privileges.
- Force browsing: guessing URLs to privileged pages as a standard user.
“Some form of” covers everything from a missing ownership check on one endpoint to a fully open admin API, so read the 100% as “check yours”, not “everyone has an open admin panel”.
THE BUILDER’S TAKEAWAYIt’s #1 because it’s easy to get wrong and easy to find.
Check the role on the server, in every admin route (Next.js App Router)

The Next.js authentication guide recommends a Data Access Layer (DAL): one server-only module that reads the session and does the authorization, which every route handler, Server Action and data request goes through. Put one requireAdmin() there and call it everywhere admin things happen.
// app/lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session' // jose jwtVerify, as in the Next.js docs
import { db } from '@/app/lib/db' // Prisma shown; any ORM works
// Who is calling? Read once per request, on the server.
export const getSessionUser = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = await decrypt(token) // empty if forged or expired
if (!session?.userId) return null
// The role comes from YOUR database: not the token, the body or the browser.
return db.user.findUnique({
where: { id: String(session.userId) },
select: { id: true, role: true, banned: true },
})
})
export async function requireAdmin() {
const user = await getSessionUser()
if (!user) return { ok: false, status: 401 } as const
if (user.banned || user.role !== 'admin') return { ok: false, status: 403 } as const
return { ok: true, user } as const
}server-only makes the build fail if a Client Component imports this file. cache dedupes the lookup within one request. decrypt is the jose jwtVerify helper from the Next.js docs; with an auth library (Auth.js, Clerk, Supabase, Better Auth…), use its server-side session call instead.
Route Handlers: every one, every method
The Next.js docs say to treat Route Handlers with the same security considerations as public-facing API endpoints: check the session (401 if missing) and then the role (403 if wrong). That’s the right-hand side of the comparison at the top of this guide. Do it in GET too: reading every user’s email is an admin action.
Server Actions are public endpoints too
// app/admin/actions.ts
'use server'
import { requireAdmin } from '@/app/lib/dal'
// A Server Action is a public POST endpoint. Check inside it.
export async function banUser(userId: string) {
const admin = await requireAdmin()
if (!admin.ok) throw new Error('Forbidden')
await db.user.update({ where: { id: userId }, data: { banned: true } })
}Next.js: treat Server Actions with the same security considerations as public-facing API endpoints. A Server Action is a POST request to the page that uses it, so “only the admin page imports it” protects nothing.
The admin page: check it there too, for UX
// app/admin/page.tsx (a Server Component: no 'use client')
import { notFound } from 'next/navigation'
import { requireAdmin } from '@/app/lib/dal'
export default async function AdminPage() {
const admin = await requireAdmin()
if (!admin.ok) notFound() // nice UX. The route and action checks are the lock.
return <AdminDashboard />
}Do the page check in the page or a leaf Server Component, not only in a shared layout: layouts don’t re-run on navigation. And the page check is the polite part. The route and action checks are the lock.
Proxy (middleware) is for redirects, not the lock
// proxy.ts (Next.js 16; called middleware.ts before v16)
// Optional, for redirects only. It is NOT the lock.
import { NextResponse, type NextRequest } from 'next/server'
import { decrypt } from '@/app/lib/session'
export async function proxy(req: NextRequest) {
const session = await decrypt(req.cookies.get('session')?.value)
if (!session?.userId) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
return NextResponse.next()
}
export const config = { matcher: ['/admin/:path*'] }In Next.js 16 the middleware file convention is deprecated and renamed to proxy; npx @next/codemod@canary middleware-to-proxy . migrates it.
The Next.js docs describe Proxy as an optional, optimistic check: read the cookie, redirect, avoid database calls. They say it should not be your only line of defense, and that most security checks belong as close as possible to your data source. Three reasons it can’t be the lock:
- The matcher decides what it covers. The docs’ own auth example uses
'/((?!api|_next/static|_next/image|.*\\.png$).*)', which skips every/apiroute. A matcher change can quietly drop coverage. - Server Actions ride on page routes. The docs warn that a matcher excluding a path also skips Server Function calls on that path, and that moving a Server Function can silently remove Proxy coverage.
- It has been bypassed. CVE-2025-29927 let a request carrying the internal
x-middleware-subrequestheader skip middleware entirely in self-hosted Next.js. It was patched in 15.2.3, 14.2.25, 13.5.9 and 12.3.5; Vercel says apps hosted on Vercel weren’t affected, and that it does not recommend middleware as the sole method of protecting routes.
THE BUILDER’S TAKEAWAYOne requireAdmin() in the data access layer, called by every route and action, is a lock you can audit.
Express: guard the API router, not the page
With a separate API (Express, Fastify, Hono), the lock goes on the API, because that’s what the attacker calls. Attach the user once from the server-side session, then put one role guard on the router that holds every admin route:
// server.js (Express)
import express from 'express'
import session from 'express-session'
import { db } from './db.js'
const app = express()
app.use(express.json())
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' } }))
// Who is calling? From the server-side session, then the database. Never from req.body.
app.use(async (req, res, next) => {
try {
req.user = req.session.userId ? await db.users.findById(req.session.userId) : null
next()
} catch (err) { next(err) }
})
const requireRole = (role) => (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'unauthenticated' })
if (req.user.role !== role) return res.status(403).json({ error: 'forbidden' })
next()
}
// One guard on the whole router: every admin route, including next month's.
const admin = express.Router()
admin.use(requireRole('admin'))
admin.post('/users/:id/ban', async (req, res) => {
await db.users.update(req.params.id, { banned: true })
res.json({ ok: true })
})
app.use('/api/admin', admin)With express-session, the session data lives on the server and the cookie carries only a signed ID. The role is read from the database on every request, so demoting someone takes effect immediately. Behind a reverse proxy (Render, Heroku, a load balancer), cookie.secure also needs app.set('trust proxy', 1).
admin.use(requireRole('admin'))runs before every route on that router. A route added next month is protected the moment it’s added, which is deny by default for the admin area.- Middleware must answer or call
next(). Express’s docs: if middleware doesn’t end the request-response cycle, it must callnext(), or the request is left hanging. Returning401/403ends it. - Mount order matters. Register the guard before the routes it protects. A route defined on
appdirectly, outside the router, isn’t covered.
Deny by default for the whole API
// Deny by default: everything under /api needs a signed-in user,
// except an explicit allowlist. Forget a check, and it fails CLOSED.
const PUBLIC = new Set(['/health', '/login', '/signup'])
app.use('/api', (req, res, next) => {
if (PUBLIC.has(req.path)) return next() // req.path is relative to /api here
if (!req.user) return res.status(401).json({ error: 'unauthenticated' })
next()
})
// …routers mounted after this line inherit the default.An allowlist of public paths is easier to review than a list of protected ones. If a path doesn’t match exactly, it gets the stricter treatment.
THE BUILDER’S TAKEAWAYPut the lock on the door attackers use: the API router.
Take the role from the session or database (Supabase RLS)
Lock 2 in the episode: the role comes from your database, never the browser. The Forger types isAdmin: true and gets nothing.
With Supabase, the browser often talks to the database directly through the Data API with your public key. Then there’s no route handler to put a check in: Row Level Security is the server-side check. Hiding the admin screen is still just display: none.
Why user_metadata is the wrong place for a role
Supabase’s RLS guide is explicit: raw_user_meta_data can be updated by the authenticated user with the update function and is not a good place to store authorization data; raw_app_meta_data cannot be updated by the user, so it’s a good place for authorization data. auth.jwt() exposes both, which makes the wrong one easy to reach for.
// ❌ Any signed-in user can run this in the console, with your public key:
await supabase.auth.updateUser({ data: { role: 'admin' } })
// …and this policy now lets them in, because user_metadata is theirs to edit:
// using ( (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' )Option A: a roles table checked by Postgres
-- 1. Roles live in their own table. No insert/update policy = users can't promote themselves.
create table public.user_roles (
user_id uuid not null references auth.users on delete cascade,
role text not null check (role in ('admin', 'moderator')),
primary key (user_id, role)
);
alter table public.user_roles enable row level security;
create policy "Users can read their own roles"
on public.user_roles for select to authenticated
using ( (select auth.uid()) = user_id );
-- 2. A helper in a schema the Data API does NOT expose.
create schema if not exists private;
grant usage on schema private to authenticated; -- policies run as the caller
create or replace function private.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.user_roles
where user_id = (select auth.uid()) and role = 'admin'
);
$$;
-- 3. Postgres enforces it, whatever the frontend shows.
create policy "Admins can update any profile"
on public.profiles for update to authenticated
using ( (select private.is_admin()) )
with check ( (select private.is_admin()) );Supabase: never create a security definer function in a schema listed under Exposed schemas. Wrapping it as (select private.is_admin()) lets Postgres evaluate it once per statement instead of once per row. Grant roles from the dashboard SQL editor or a server-side script, never from the client.
Because the helper looks the role up on every query, removing someone from user_roles takes effect on their next request. Supabase’s RBAC guide shows a fuller version of this pattern: an app_role enum, a role_permissions table, a Custom Access Token Hook that adds a user_role claim, and an authorize() function used in policies.
Option B: a role in app_metadata
-- Alternative: a role in app_metadata, which users cannot edit.
create policy "Admins can delete any post"
on public.posts for delete to authenticated
using ( ((select auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin' );// scripts/make-admin.ts: run on a server or your machine, never in the browser.
import { createClient } from '@supabase/supabase-js'
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // secret: server only
)
await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
})Supabase: auth.admin.updateUserById() should only be called on a server. Never expose your service_role key in the browser.
- RLS on, no policy = no access. Supabase: once RLS is enabled, no data is accessible through the API with the publishable key until you create policies. That’s deny by default, built in.
- Keep
roleout of tables users can update. A “users can update their own profile” policy plus arolecolumn onprofilesis a self-promotion button. - Same rule for Edge Functions and your own API: verify the user server-side, then read the role from the database or
app_metadata.
More on policies in our Supabase Row Level Security guide.
THE BUILDER’S TAKEAWAYIf users can write it, it isn’t a role. It’s a wish.
Deny by default, then test with a normal user’s token

OWASP’s first prevention rule for broken access control: except for public resources, deny by default. The OWASP Authorization Cheat Sheet adds: validate permissions on every request, and include access-control logic in unit and integration tests. In practice:
- Public is the exception you write down. Login, signup, health check, marketing pages. Everything else needs a session; everything under admin needs the admin role.
- One guard, reused.
requireAdmin()in the DAL, one Express router, RLS on every table. Not a check pasted into each handler with small differences. - When the check can’t decide, it says no. Missing session, unknown role, database error:
401/403/500, never “allow”.
One “normal user gets 403” test per admin route
// tests/admin-access.test.mjs (node --test, against a local or staging server)
import { test } from 'node:test'
import assert from 'node:assert/strict'
const BASE = process.env.TEST_BASE_URL ?? 'http://localhost:3000'
const USER = process.env.TEST_USER_COOKIE // a seeded, signed-in NORMAL user
// One line per admin route. Add a route, add a line.
const ADMIN_ROUTES = [
['GET', '/api/admin/users'],
['POST', '/api/admin/ban'],
['DELETE', '/api/admin/posts/test-post'],
['POST', '/api/admin/refunds'],
]
for (const [method, path] of ADMIN_ROUTES) {
test(`${method} ${path}: anonymous gets 401`, async () => {
const res = await fetch(BASE + path, { method, redirect: 'manual' })
assert.equal(res.status, 401)
})
test(`${method} ${path}: normal user gets 403`, async () => {
const res = await fetch(BASE + path, { method, redirect: 'manual', headers: { cookie: USER } })
assert.equal(res.status, 403)
})
}Seed a normal test user and pass its session cookie in TEST_USER_COOKIE. Run against local or staging data, never production users. If your auth returns redirects instead of 401s, assert on that instead, but assert something.
This is lock 3 in the episode, the one that bounces the Courier, and the spec fits in one line: if it works with a normal user’s token, it’s broken. Add the admin user too, and assert it gets 200, so you know the test actually reaches the route.
Make an unguarded route fail CI
// tests/admin-routes-guarded.test.mjs: no admin route ships without the guard
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readdir, readFile } from 'node:fs/promises'
test('every app/api/admin route calls requireAdmin()', async () => {
const files = (await readdir('app/api/admin', { recursive: true }))
.filter((f) => /route\.[jt]sx?$/.test(f))
assert.ok(files.length > 0, 'no admin routes found')
for (const f of files) {
const src = await readFile(`app/api/admin/${f}`, 'utf8')
assert.match(src, /requireAdmin\(/, `${f} never calls requireAdmin()`)
}
})A cheap guardrail, not proof: it catches the route someone forgot. The 403 tests above are what prove the lock works. readdir with recursive needs Node 20.1 or newer.
THE BUILDER’S TAKEAWAYDon’t test the button. Test the lock.
Log every denied admin call (and every admin action)
OWASP’s prevention list includes logging access control failures and alerting admins when appropriate, for example on repeated failures, and rate limiting API access to slow automated tools. A normal user hitting /api/admin/* is either a bug in your UI or someone probing. Either way, you want to know.
// app/lib/access-log.ts
import 'server-only'
export function logDenied(e: {
status: 401 | 403; method: string; path: string; userId?: string; ip?: string | null
}) {
// One parseable line per denied call. Never log cookies, tokens or request bodies.
console.warn(JSON.stringify({ event: 'access_denied', at: new Date().toISOString(), ...e }))
}
// In a route handler:
// if (!admin.ok) {
// logDenied({ status: admin.status, method: req.method, path: new URL(req.url).pathname,
// ip: req.headers.get('x-forwarded-for') })
// return new Response(null, { status: admin.status })
// }- Log denials: status, method, path, user ID if known, time, IP. One structured line each, so you can count them.
- Alert on patterns, not single events: one user collecting dozens of
403s, or401s sweeping your admin paths. - Never log secrets: no cookies, tokens,
Authorizationheaders or full request bodies. - Record successful admin actions too, in a table only the server writes, so “who exported the whole user list at 3 a.m.” has an answer.
-- Who did what, as admin. Written by the server, read by humans.
create table admin_audit_log (
id bigint generated always as identity primary key,
actor_id uuid not null, -- the admin, from the session
action text not null, -- 'user.ban', 'refund.create', 'role.grant'
target_id text,
created_at timestamptz not null default now()
);THE BUILDER’S TAKEAWAYA lock you never check the camera on is half a lock.
Rob your own app before someone else does

In the episode, Gemini’s crew planned the job for three weeks and pulled it off in four seconds: the vault was display: none, and the checkbox said admin only, but the API never read the checkbox. Heist #2, same crew, the next night, failed lock by lock. That’s the test that counts. A hidden button only proves the page works. A request to the API with a normal user’s token proves the lock does.
The three locks are small: check the role on the server in every admin route, take that role from your session or database, and deny by default with a 403 test per route. Our other guides cover the same kind of gap elsewhere: Supabase Row Level Security, keeping API keys out of the browser, protecting a paid AI endpoint and verifying Stripe webhooks.
THE BUILDER’S TAKEAWAYAllegedly.
QUESTIONS PEOPLE ASK
FAQ
Is hiding the admin button or redirecting non-admins enough?
No. Both run in the browser, which the visitor controls, and neither changes what your API does. Anyone can call the admin endpoint directly with fetch or curl. OWASP’s own example scenario describes an app that puts all its access control in the front end and is bypassed with one curl. Check the role on the server in every admin route.
How do I protect admin routes in the Next.js App Router?
Create a server-only Data Access Layer with a requireAdmin() that reads the session on the server and loads the role from your database. Call it at the top of every admin Route Handler (return 401 with no session, 403 for the wrong role), every Server Action, and the admin page itself. The Next.js docs say to treat Route Handlers and Server Actions like public API endpoints.
Is Next.js middleware (Proxy) enough to protect an admin page?
No. The Next.js docs describe Proxy (called middleware before v16) as an optional, optimistic check that shouldn’t be your only line of defense; its matcher may not cover /api routes or Server Actions. CVE-2025-29927 also let a crafted x-middleware-subrequest header skip middleware in self-hosted versions before 15.2.3, 14.2.25, 13.5.9 and 12.3.5. Use it for redirects, and check the role in each route and action.
Where should I store a user’s admin role?
Somewhere only your server can write: a role column or user_roles table that users have no update access to, or Supabase app_metadata. Read it on the server on every request. Never trust a role from localStorage, the request body, a plain cookie, a JWT you only decoded, or Supabase user_metadata.
Can I put the role in a JWT?
You can, if the token is signed by your server or auth provider and you verify the signature on every request, not just decode it. Remember the claim is a snapshot: Supabase notes auth.jwt() won’t reflect a changed app_metadata until the token is refreshed. For instant demotion, look the role up in the database.
Why shouldn’t I use Supabase user_metadata for roles?
Because the user can change it. Supabase’s RLS docs say raw_user_meta_data can be updated by the authenticated user and is not a good place to store authorization data, while raw_app_meta_data cannot be updated by the user. A signed-in user can call supabase.auth.updateUser({ data: { role: 'admin' } }) from the console.
Should an admin route return 401, 403 or 404?
401 when there’s no valid session, 403 when the user is signed in but not allowed. Some apps return 404 for admin URLs to avoid confirming they exist; that’s fine as long as the check happens on the server and your tests assert the non-200 status you chose.
How do I test for broken access control?
For every admin route, send the request with no session (expect 401) and with a normal user’s session (expect 403), and with an admin session (expect 200). Automate it in CI with node:test or your test runner, add a check that every admin route file calls your guard, and run one manual curl with a normal user’s cookie against production.
THE BIGGER PICTURE
The takeaway for builders.
A frontend-only admin check doesn’t look broken. The button is hidden, the redirect works, and every request that reaches the API is answered anyway. Don’t guard the vault with display: none. Check the role on the server in every admin route, take it from your session or database, deny by default, and prove it by calling each admin route with a normal user’s token. A free Aster repository audit can take a second look at the rest of your code.
The source notebook 11 links
Go a little deeper. These are the sources linked in this story.
- 01OWASP Top 10:2025, A01 Broken Access Controltop10.owasp.org
- 02Next.js Docs: How to implement authentication (Data Access Layer, Route Handlers, Server Actions, Proxy)nextjs.org
- 03OWASP Cheat Sheet Series: Authorization Cheat Sheetcheatsheetseries.owasp.org
- 04OWASP Top 10:2021, A01 Broken Access Controltop10.owasp.org
- 05Next.js Docs: proxy.js file convention (formerly middleware; matcher, execution order, migration)nextjs.org
- 06Vercel: Postmortem on Next.js middleware bypass (CVE-2025-29927)vercel.com
- 07Express Docs: Using middleware (application-level and router-level)expressjs.com
- 08Supabase Docs: Row Level Security (auth.jwt(), app_metadata vs user_metadata, security definer functions)supabase.com
- 09Supabase Docs: Custom Claims and Role-based Access Control (RBAC)supabase.com
- 10Supabase JS reference: auth.updateUser()supabase.com
- 11Supabase JS reference: auth.admin.updateUserById()supabase.com
PUT THE CONTEXT TO WORK
A second look.
Before you ship.
Connect your GitHub repository and see what needs attention. Your first audit is free.
Start my free audit

