How to Add Role-Based Permissions to an AI App
A worked example for adding roles like owner, admin, editor, and viewer to an AI-built app, and why the permission check has to live on the server, not the button.
Role-based permissions decide what a signed-in user is allowed to see and do inside your app. The model most teams actually need is simpler than it sounds: a small set of named roles such as owner, admin, editor, and viewer, a table that maps each role to the actions it covers, and a check that runs on the server for every request, not just a hidden button in the interface. If you are adding role-based permissions to an AI-built app for the first time, the part that trips people up is not choosing the roles. It is remembering that the real security boundary sits at the API and the database, not the frontend component that decided not to render a delete button.
What role-based access control actually is
Authentication answers "who is this user." Authorization, specifically role-based access control (RBAC), answers "what is this user allowed to do." RBAC groups permissions into named roles instead of assigning them one at a time. A five-person team app might use four admin, editor, viewer-style roles plus owner: owner controls everything including billing and deleting the workspace, admin manages users and settings, editor creates and edits content, viewer gets read-only access. Every user gets exactly one role per account or workspace, and every protected action checks that role before it runs.
This differs from per-user permission flags scattered across a users table, which drift out of sync fast, and from letting every logged-in user do everything, which is what most AI-generated starter apps ship with by default. Role-based permissions also assume accounts and sessions already exist. If you have not built that part yet, start with adding user accounts to an AI-built app before layering roles on top of it.
A role-based access control worked example: two ways to model it
There are two common shapes for storing roles. Pick based on how many roles you expect and how often they will change.
Enum-based roles, for small apps
create type app_role as enum ('owner', 'admin', 'editor', 'viewer');
alter table memberships
add column role app_role not null default 'viewer';One column, one fixed list of roles, done. Every permission check becomes a simple comparison: if the user's role is owner or admin, allow it. This is the right choice for most small SaaS apps and internal tools, and it is what most AI app builders scaffold by default. Do not overbuild past it until you actually need to.
A roles and role_permissions mapping, for apps that need it
Once permissions need to be added, removed, or assigned differently per customer, a common ask once you have enterprise accounts wanting a custom role, a fixed enum stops being enough. Building RBAC for a SaaS app at that stage usually means a proper many-to-many mapping instead of a single column:
create table roles (
id uuid primary key default gen_random_uuid(),
name text not null unique
);
create table permissions (
id uuid primary key default gen_random_uuid(),
action text not null unique -- e.g. 'project.delete', 'billing.view'
);
create table role_permissions (
role_id uuid references roles(id) on delete cascade,
permission_id uuid references permissions(id) on delete cascade,
primary key (role_id, permission_id)
);
create table user_roles (
user_id uuid references auth.users(id) on delete cascade,
role_id uuid references roles(id) on delete cascade,
primary key (user_id, role_id)
);A permission check becomes a join: does this user have a role that grants this permission. It costs more to build and query than a single column, so do not reach for it until the enum genuinely stops covering your needs. Most apps under a few thousand users never outgrow four fixed roles.
Approach | Use it when |
|---|---|
Enum role column | Roles are fixed, small teams, moving fast |
roles + role_permissions | Custom roles per customer, permissions change without a deploy |
Where the permission check has to live
Wherever you put a role check, the frontend cannot be the only place it exists. A browser is not a trusted environment. Anyone can open devtools, edit the app's JavaScript, or skip the UI and call your API directly with curl or Postman using their own valid session. If the only thing standing between a viewer and a delete action is if (role === 'admin') return null in a React component, that viewer can still call the delete endpoint directly, and it will process the request, because nothing on the server asked whether they were allowed to.
Hiding UI elements a user should not use is good UX. It is not access control. The two solve different problems: hiding a button stops confused clicks from people who do not need an option right now; a server-side check stops someone who is not allowed from executing the action at all. You want both. Only one of them is actually security.
A worked example: gating one route and one button correctly
Take a "delete project" action. Only owner and admin roles should be able to run it.
Wrong: the check exists only in the component
// DeleteButton.jsx
function DeleteButton({ project, userRole }) {
if (userRole !== 'owner' && userRole !== 'admin') return null;
return <button onClick={() => deleteProject(project.id)}>Delete</button>;
}
// api/projects/[id].js
export async function DELETE(req, { params }) {
await db.projects.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}The button hides correctly for the right people. The route does not check anything at all. Any logged-in user, regardless of role, can delete any project by calling the endpoint directly.
Correct: the UI hides it, the API enforces it
// DeleteButton.jsx stays the same, it's a legitimate UX nicety
// api/projects/[id].js
export async function DELETE(req, { params }) {
const user = await getSessionUser(req);
const membership = await db.memberships.findFirst({
where: { userId: user.id, projectId: params.id },
});
if (!membership || !['owner', 'admin'].includes(membership.role)) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
await db.projects.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}Same UI, same button. The difference is four lines on the server that look up the caller's actual role for that project and refuse the request if it does not qualify. That check, not the missing button, is what makes the route safe.
If the roles you are adding exist mainly to power a staff-only admin area, adding an admin dashboard to an AI-built app covers the UI side in more depth. The permission model underneath it is the same one described here.
Postgres row-level security: the strongest version of this pattern
If your backend runs on Postgres, whether Supabase, Neon, or plain Postgres, you can push the same check into the database with row-level security (RLS). An API check has to be written correctly on every route by hand. An RLS policy is enforced by Postgres on every query against that table, no matter which code path reaches it, including a future endpoint someone forgets to guard.
alter table projects enable row level security;
create policy "owners and admins delete projects"
on projects
for delete
to authenticated
using (
exists (
select 1 from memberships
where memberships.project_id = projects.id
and memberships.user_id = auth.uid()
and memberships.role in ('owner', 'admin')
)
);This does not replace the API-level check, it backs it up. If a bug ever lets a request reach the database without a route-level permission check, RLS is the layer that still says no. For anything touching money, personal data, or destructive actions, treat Postgres row security policies as the baseline, not the extra credit.
The common mistake: permission checks that only exist in the frontend
This is the single most common access control bug in apps built quickly with AI tools, and it is easy to see why it happens. Ask an AI coding assistant to "only show the delete button to admins" and it will generate exactly that: a conditional render in the component. It answered the literal request. It did not add a server-side check, because you did not ask for one, and the code looks completely finished. The button disappears for the right people, the demo works, everyone moves on. The same pattern, looks finished but is not, shows up anywhere you let an AI tool scaffold security-sensitive code, worth keeping in mind whenever you are deciding how much access to give AI over your data.
The only way to catch this is to test the API directly, not the UI. Log in as your lowest-privilege role, grab the session token or cookie from devtools, and call the sensitive endpoints using that session while bypassing the UI entirely.
curl -X DELETE https://yourapp.com/api/projects/123 \
-H "Authorization: Bearer <viewer-session-token>"If that returns a success response for a role that should never be allowed to delete a project, fix the route, not the button.
A short checklist before you ship it
Role | Typical access |
|---|---|
Owner | Everything, including billing and deleting the workspace |
Admin | Manage users, settings, and all content |
Editor | Create and edit content, no user or billing management |
Viewer | Read-only |
Before you ship a new role or a new protected action, confirm:
Every route that changes or deletes data checks the caller's role server-side, not just the UI
Every route that reads sensitive data checks role or ownership, not just whether the caller is logged in
If you are on Postgres, row-level security is enabled on tables holding user or billing data
You have tested each role by calling the API directly, not only by clicking through the UI as that role
Role changes take effect quickly, either on the next request or via a short-lived session, not only after the next login
For the fuller sequence of decisions that building an app with AI involves, from choosing a database to launch, see the complete guide to building an app with AI.
Frequently asked questions
What is the difference between role-based access control and just checking user.isAdmin?
user.isAdmin is a single boolean check hardcoded for one case. RBAC groups a full set of permissions into named roles, such as owner, admin, editor, and viewer, reusable across every protected route instead of one-off flags for each new feature.
Do I need a permissions table, or is a role column enough?
A single enum role column is enough for most small apps with three or four fixed roles. Add a proper roles and role_permissions mapping only once permissions need to vary per customer or change without a code deploy.
Is hiding a button in the UI a valid permission check?
No. Hiding a button improves the experience for users who should not use a feature, but it does not stop them calling the underlying API directly with their own session. The check has to run again on the server for every request.
Should I use Postgres row-level security or check roles in my API code?
Both, if you can. API-level checks give you flexible error messages and business logic. Row-level security at the database layer is the backstop that still blocks the request even when a route is missing its own check.
How many roles should a small app start with?
Most apps do not need more than four: owner, admin, editor, viewer. Add a role only when an existing one is clearly doing two different jobs, not in anticipation of needs you do not have yet.
How did this land?
About the author

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.


