How to Add an Admin Dashboard to an AI-Built App

Hiding a delete button from regular users isn't access control, it's decoration. Here's how to gate an admin dashboard the way that actually holds: row-level security enforced by the database, with a worked booking-app example for owners versus customers.

Steve Jefferson
Steve Jefferson
Developer Advocate
3 August 20261 min read

An admin dashboard is not secure because you hid the delete button from regular users. It's secure because the database itself refuses to hand back or modify rows a user isn't allowed to touch, no matter what request hits it. If you're building an app with an AI tool and you add an "admin view" by checking if (user.role === 'admin') in the frontend, you haven't built access control. You've built a UI suggestion that anyone with browser dev tools can ignore.

This is the single most common security gap in AI-built apps, because AI coding assistants are very good at generating convincing-looking admin panels and very bad at reminding you that the panel isn't the security boundary. The fix isn't complicated, but it does mean pushing the actual rule enforcement down to where the data lives.

The button isn't the boundary

Here's the failure mode in concrete terms. Say you prompt your AI builder: "add an admin dashboard where staff can see all bookings and cancel any of them." It generates a /admin route, checks the logged-in user's role, and if they're an admin, renders a table with every booking and a cancel button. Looks done.

But the actual data request, the API call or database query that fetches "all bookings," usually doesn't have any role check on it at all. It just fetches all bookings. The only thing standing between a regular customer and everyone else's booking data is whether they know the URL or can call the same endpoint directly, which takes about ten seconds with a browser's network tab open.

The frontend role check answers "should I show this button." It never answers "is this request allowed." Those are different questions, and only the second one is security.

Where the rule actually has to live

The rule has to be enforced by whatever sits between the request and the data: your API layer, or better, the database itself. PostgreSQL has a built-in mechanism for exactly this, called row-level security (RLS). Instead of writing if (isAdmin) scattered across every route that touches a table, you attach a policy directly to the table. The database then applies that policy to every single query against it, regardless of what app, script, or admin tool sent the query.

The PostgreSQL documentation on row security policies is explicit about this: policies are enforced inside the database server itself, so "no application-side configuration can bypass the policies" except for roles explicitly granted BYPASSRLS or the table owner. That's the property you want. It means even if you or the AI tool later ships a new API route and forgets to add a permission check, the database still won't return rows it shouldn't.

Two clauses do the work in a policy:

  • USING controls which existing rows a user can see or touch, for SELECT, UPDATE, and DELETE.

  • WITH CHECK controls what a user is allowed to write, for INSERT and UPDATE.

If you only ever set USING, an update policy can accidentally let a user overwrite a row's ownership field to something else. Both clauses matter for anything that writes data.

If you're using Supabase, which layers Postgres RLS with its auth system, the same mechanism applies, plus a helper function, auth.uid(), that returns the logged-in user's ID inside a policy expression.

Worked example: a booking app with an owner and customers

Say you built a booking app (a common first project when people build an app with AI, and specifically a booking app with AI). Two roles matter here:

  • A customer should only ever see and cancel their own bookings.

  • The owner/staff should see every booking across every customer, and be able to cancel any of them.

Start with row-level security turned on for the table:

sql
alter table bookings enable row level security;

Now the customer-facing policy. Customers can only read rows where they're the owner of the booking:

sql
create policy "customers view own bookings"
on bookings
for select
to authenticated
using ( (select auth.uid()) = customer_id );

And they can only cancel (update) their own rows, and can't reassign a booking to someone else while they're at it:

sql
create policy "customers cancel own bookings"
on bookings
for update
to authenticated
using ( (select auth.uid()) = customer_id )
with check ( (select auth.uid()) = customer_id );

For the admin dashboard, you need a way to identify staff. The reliable way is to store the role as a custom claim on the user's JWT rather than trusting a client-supplied flag, since Supabase's documentation on custom claims and role-based access control notes the role should live in app_metadata, a field the user themselves cannot edit. Then the admin policy checks that claim instead of the row owner:

sql
create policy "staff view all bookings"
on bookings
for select
to authenticated
using ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'staff' );

create policy "staff cancel any booking"
on bookings
for update
to authenticated
using ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'staff' )
with check ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'staff' );

Postgres combines multiple permissive policies on the same table with OR. So a staff member matches the staff policy, a regular customer matches the customer policy, and neither one can see rows the other policy wasn't written for. The admin dashboard's "see everyone's bookings" query and the customer app's "see my bookings" query can literally be the exact same select * from bookings, and the database returns different rows to each depending on who's asking. That's the difference between real access control and a hidden button: the enforcement doesn't care which screen the request came from.

Building the dashboard UI on top of this

Once the data layer enforces who sees what, the admin dashboard itself gets simpler, not harder. You're not writing custom authorization logic per screen. You just query the tables normally and let RLS filter the results:

  • The staff dashboard's "all bookings" table runs the same query a customer's "my bookings" screen runs. It returns more rows for staff because the policy allows it, not because the frontend asked nicely.

  • Cancel buttons on both screens can call the same update endpoint. A customer's request to cancel someone else's booking simply won't match any policy, and the update affects zero rows, instead of quietly succeeding.

  • If your app has user accounts already wired up, attaching roles is mostly a matter of adding a role field to user metadata and referencing it in the policy, not rebuilding the auth flow.

This matters even if you decided you don't need a full custom backend and you're using a managed database with an auto-generated API, since those APIs typically talk to Postgres directly and inherit whatever RLS policies you've defined. Some AI app builders, including Swarmz, generate this kind of managed-Postgres backend by default, which means the RLS policies you write are the actual security layer for both the customer app and the internal dashboard, not an extra thing bolted on top.

Where this still goes wrong

A few mistakes show up repeatedly, even in apps where someone clearly tried to do RLS:

  1. Forgetting `WITH CHECK` on updates. A USING clause alone lets a staff member see and start editing a row, but without WITH CHECK, nothing stops them from writing values that shouldn't be allowed, like reassigning a booking's customer_id to their own account.

  2. Stale roles in JWTs. If a role lives in a token claim, revoking someone's admin access in the database doesn't invalidate tokens they already hold. They keep admin-level access until that token expires or they log out and back in. If you need instant revocation, check the role against a live table instead of trusting the token alone for anything sensitive.

  3. Service-role keys leaking into the frontend. Admin tools built quickly sometimes use a "service" or "bypass RLS" key to make the dashboard queries simpler. If that key ends up in client-side code, it defeats every policy you wrote, for every table, for everyone.

  4. No policy on a table at all. In Postgres, once RLS is enabled on a table with zero policies defined, the default is deny-all, not allow-all. That's the safe failure mode, but it also means a table added later without a matching policy will silently return nothing to non-privileged roles, which looks like a bug rather than a missing permission and can send you debugging the wrong thing.

Testing it before you ship

Don't take the AI's word that the dashboard is locked down. Test it directly:

  • Log in as a plain customer account and try calling the "cancel any booking" endpoint against someone else's booking ID directly, not through the UI. It should fail or affect zero rows.

  • Log in as staff and confirm the dashboard actually returns every customer's bookings, not just the staff member's own.

  • Temporarily remove a user's admin role in the database and confirm their existing session loses access on their next request, or at least understand exactly when it will.

If you had AI generate the policies, this is also a case where it's worth having a second pass review the generated code specifically for USING/WITH CHECK gaps and role checks that reference client-supplied data instead of the authenticated session. Those are the two mistakes that look fine in a demo and fail in production.

The pattern generalizes past bookings. Any AI-built app with an internal or admin view, inventory management, support ticket queues, order fulfillment, works the same way: define who can see and change which rows as database policies, then build the dashboard as a thin UI over queries that are already safe by the time they run.

Frequently asked questions

Is hiding the admin button in the UI enough to secure a dashboard?

No. Hiding a button only changes what's rendered on screen. Anyone who can call the underlying API or database query directly, which takes seconds with browser dev tools, bypasses it entirely. Real protection has to be enforced where the data lives, not in the interface.

What is row-level security and why does it matter for admin dashboards?

Row-level security (RLS) is a PostgreSQL feature that attaches access policies directly to a database table, so the database itself filters which rows a query can see or change based on who's asking. It applies no matter which app, route, or admin tool sends the request, which closes the gap a frontend-only role check leaves open.

How do I let an admin see all customer data while regular users only see their own?

Write two permissive RLS policies on the same table: one that lets a row's owner see their own rows (using auth.uid() = customer_id in Supabase, for example), and one that lets a role like staff or admin see every row (checking a role claim). Postgres combines permissive policies with OR, so each user only gets access from the policy that applies to them.

Can a hidden admin role in the frontend still leak data?

Yes, if the API or database query behind that screen has no role check of its own. The frontend role flag only controls what renders. If the query for 'all bookings' or 'all customers' has no matching database policy, any authenticated user who finds or guesses the request can pull the same data an admin sees.

Do I still need role-based access control if I'm not writing a custom backend?

Yes. Managed backends and auto-generated APIs built on Postgres, including the kind many AI app builders generate by default, still run every query through the database. If you never define row-level security policies, you're relying only on frontend logic, so RBAC still needs to be enforced with database policies even without custom server code.

Once the dashboard is live, the recurring bill is worth understanding too. how much it costs to run an AI-built app for a breakdown of hosting, database, and AI API costs as usage grows.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.