Skip to content
All guides

Admin page only hidden in the UI: how to enforce admin access properly

By Obscura Security · Updated · React, Next.js, Supabase, Lovable, Bolt, Cursor

Short answer

If your admin checks only exist in React (a hidden button, a protected route, a role in localStorage), anyone can skip the interface and call your API or database directly. Enforce the role on the server or with database policies, and store roles in a table that users cannot write to.

If the only thing between a normal user and your admin tools is a button that doesn’t show, the admin tools aren’t protected.

Why this happens

Code written by an AI tool or in a hurry tends to put the check where the problem is visible. In the React code it adds:

{user.role === 'admin' && <DeleteUserButton />}

or a route guard that redirects non-admins away from /admin. The page looks right. A normal user can’t see the buttons.

But the button calls an API route or a database query, and that endpoint has no idea which button was clicked. It just does what it’s asked. Anyone can run the same request from the browser console, from curl or from a script. This is the same hole as users seeing other users’ data, applied to actions and not records.

A second version of the problem is where the role itself comes from. If it’s read from localStorage, a cookie the browser can edit, or a role field on a profile row that the user is allowed to update, then the user can simply set it to admin.

How to check your own app

Use your own app with two accounts: a normal test user and an admin.

  1. As the admin, use the admin feature (say, deleting a user) with the browser’s Network tab open. Note the request it makes, such as POST /api/admin/delete-user or a call to /rest/v1/profiles.
  2. Log in as the normal user. Send the same request, either by right-clicking it in the Network tab and copying it as curl, or from the console with fetch. If it succeeds, the check is only in the interface.
  3. Look at where your app reads the role. In developer tools, open Application → Local Storage. If you see role: "user", edit it to admin and reload. If the admin screens appear, that’s the display bug, and you need to confirm the server side as well.
  4. If you use Supabase, check whether a normal user can update their own role. As that user, in the browser console of your app:
await supabase.from('profiles').update({ role: 'admin' }).eq('id', myUserId)

If this succeeds, anyone can make themselves an admin.

The fix

1. Check the role on the server for every admin action

Work out the role from the session on the server, using a source the user can’t edit:

// app/api/admin/delete-user/route.ts
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

export async function POST(req: Request) {
  const user = await getCurrentUser()
  if (!user) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 })

  // Read the role from your database, not from the request.
  const dbUser = await prisma.user.findUnique({
    where: { id: user.id },
    select: { role: true },
  })
  if (dbUser?.role !== 'admin') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
  }

  const { targetId } = await req.json()
  await prisma.user.delete({ where: { id: targetId } })
  return new NextResponse(null, { status: 204 })
}

Put this check in a small shared function (such as requireAdmin()) and call it at the top of every admin route, so it is hard to forget one.

2. If you use Supabase, put roles in a table users cannot write

create table public.user_roles (
  user_id uuid primary key references auth.users (id) on delete cascade,
  role text not null check (role in ('admin', 'member'))
);

alter table public.user_roles enable row level security;

-- Users may read their own role. There are no insert, update or delete
-- policies, so nobody using the public key can change a role.
create policy "Read own role"
  on public.user_roles for select
  to authenticated
  using ( (select auth.uid()) = user_id );

Then use that table in policies on the data admins should reach:

create policy "Admins read all orders"
  on public.orders for select
  to authenticated
  using (
    exists (
      select 1 from public.user_roles
      where user_id = (select auth.uid()) and role = 'admin'
    )
  );

Grant yourself the first admin role from the SQL editor:

insert into public.user_roles (user_id, role)
values ('YOUR-USER-UUID', 'admin');

If you had a role or is_admin column on a profiles table that users can update, remove the column, or make sure no policy lets a user change it.

Supabase also lets you keep a role in app_metadata, which the user cannot edit. A roles table is simpler to inspect and change, and takes effect straight away without waiting for a new login token.

3. Keep the interface check, for display only

Hiding the button is still good design. Just treat it as a convenience for honest users, never as protection.

4. Test again

Repeat the copied request from step 2 above as the normal user. It should now return 403, or in Supabase an empty result or a permission error.

Mistakes to avoid

  • Reading the role from the request. A header, cookie, query string or body field like isAdmin=true is controlled by the caller.
  • Putting the role in user_metadata. Users can update it themselves.
  • A policy that mentions admin but depends on a writable column. using (is_admin) on a profiles table is only as strong as who can update is_admin.
  • Using the service_role key to make the admin panel “just work”. This removes every database check. See Supabase service role key exposed.
  • Checking admin on the page route but not the API route. Both need it, and the API route matters more.

Common questions

Is a protected route in React Router or Next.js middleware enough?
A protected route only controls which page the browser shows. The data comes from your API or database, and those must check the role themselves. Next.js middleware can help, but check the role again inside each route handler or server action.
Is it OK to store the user's role in localStorage or a cookie the client can edit?
Only for choosing what to display. Anyone can open developer tools and change it. The server must work out the role itself, from a session or a table it controls.
Can I keep the role in Supabase user_metadata?
No. user_metadata (raw_user_meta_data) can be edited by the signed-in user through the client library. app_metadata cannot be changed by the user, so it is safe for roles, but a roles table is easier to manage and check.
How do I make myself an admin if users can't write the roles table?
Insert the row yourself from the Supabase SQL editor or from a trusted server script. Users should never have a way to grant themselves a role.

Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].