Dashboard

How to Add Role-Based Access Control to an AI-Built App

A minimal RBAC pattern for an AI-built app: three roles, one server-side check, and the exact prompts that stop an agent from shipping a frontend-only version.

Steve Jefferson
Steve Jefferson
Developer Advocate
21 September 20261 min read

Most AI-built apps start with two access levels: signed in, and not signed in. That works until the first day someone asks "can we let our support team see orders but not edit pricing" or "can the finance contractor see invoices without seeing every customer's personal data." Role-based access control, RBAC, is the standard answer, and it is a small enough pattern to add properly even to an app you mostly generated rather than hand-built.

The Three Pieces You Actually Need

RBAC sounds like a big system but at its core is three tables and one check, repeated everywhere it matters:

Piece

What it stores

Example

Roles

A named set of permissions

admin, editor, support, viewer

Permissions

A specific allowed action on a resource

orders:read, orders:write, invoices:read

Role assignments

Which user has which role, per workspace if you support multiple

user_123 has role editor in workspace_45

A role is just a bundle of permissions with a name. The bundling is what makes this manageable: you assign a user "support" once, and every permission that role carries follows automatically, rather than granting or revoking a dozen individual permissions per person.

Where the Check Actually Belongs

The single most common mistake is checking roles only in the frontend, hiding a button if the user is not an admin. That stops confused clicks, it does nothing against a request sent directly to your API. Every permission check has to happen server-side, at the point where the action executes, not just at the point where the button is drawn.

For an AI-built app on a typical stack, that means the check belongs in your API route handler or edge function, before the database call, not in a component's conditional rendering. If you are prompting an AI coding agent to add this, be explicit about that requirement: "add the permission check inside the API handler that performs the update, not just in the UI that shows the edit button." Agents will happily do the easier frontend-only version if you do not specify otherwise, because it is a smaller diff and passes a casual visual test.

A Minimal Schema That Scales

For most AI-built apps, you do not need a fully generalized permissions engine on day one. Start with roles as an enum and a permissions table only when you actually have more than four or five roles or need per-customer customization:

  1. A `role` column on your user-workspace membership table (admin, editor, viewer, or whatever your app's roles are), simple and fast to check.

  2. A single server-side helper function, `can(user, action, resource)`, that every API route calls before performing a write or returning sensitive data.

  3. Only move to a full roles-and-permissions-table system once you need custom roles per customer, since that is real added complexity you should not pay for until you need it.

Prompting the Build Correctly

When asking an AI coding agent to implement this, give it the specific roles and what each can and cannot do, not just "add RBAC":

Weak prompt

Strong prompt

Add role-based access control to my app

Add three roles: admin (full access), editor (can create and edit orders, cannot delete), viewer (read-only). Enforce this in every API route, not just the UI.

Make sure only admins can delete things

Every DELETE endpoint must check the caller's role server-side before executing, and return a 403 with no data if the check fails.

Also ask explicitly for a negative test: "write a test that confirms a viewer role gets a 403 when calling the update endpoint directly, bypassing the UI." This catches the frontend-only mistake before it ships, since a passing test that hits the API directly cannot be fooled by a hidden button.

Common Gaps Once the Basics Work

  • Forgetting to check role on read endpoints, not just writes: a viewer who should not see financial data can often still fetch it directly from an API that only checks role on the write path.

  • New API routes added later that skip the permission check because the pattern was not documented anywhere the next prompt or the next developer would see it.

  • Role checks that compare against a role name typed as a string in multiple places, drifting out of sync when a role is renamed in one place and not another. Centralize the role list as a single source of truth (a constant or enum) referenced everywhere.

a complete guide to building an app with AIadding multi-tenancy to an AI-built apppreventing prompt injection in your AI app

what to do when someone reports a security bug in your app

FAQ

Do I need a real permissions table, or is a role column enough?

A role column with a small number of fixed roles is enough for most apps. Move to a full permissions table only when you need custom roles per customer or a number of distinct permissions large enough that hardcoding them per role becomes unmanageable, usually well beyond five or six roles.

Should role checks live in middleware or in each route handler?

Middleware that checks authentication (are you logged in) is a good default. Authorization (are you allowed to do this specific thing) is safer checked explicitly in each route handler, since a shared middleware rule is easy to get subtly wrong for one particular action that needs a different rule than the rest.

How do I test that RBAC actually works, not just that the UI hides buttons?

Write API-level tests that call each protected endpoint directly with a token belonging to each role, and assert the correct success or 403 response. If your only test is clicking through the UI as different users, you are testing the UI, not the security boundary.

What happens if a user has more than one role?

Decide explicitly whether roles are additive (permissions from all assigned roles combine) or the highest-privilege role wins, and encode that rule once in your `can()` helper rather than leaving it to be reasoned out differently in each route handler.

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.