# How to Add Role-Based Access Control to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Role-based access control needs three things wired together: roles stored in Supabase auth app_metadata (not user_metadata, so users can't self-modify them), RLS policies on every table enforcing those roles at the database level, and a Flutter GoRouter guard that blocks protected screens before they render. With FlutterFlow + Supabase you can ship a working admin/editor/viewer system in 4–8 hours for $0–$25/month.

## What Role-Based Access Control Actually Is

Role-based access control (RBAC) is the system that decides what each user is allowed to see and do based on a label — their role. In a typical mobile app you might have three: admin (manages users and all content), editor (creates and edits content but cannot manage other users), and viewer (read-only). The critical product decision is where that role lives. Storing it in Supabase auth app_metadata — rather than in a user-editable profile field — means only your backend can change it, not a clever user who intercepts their own API call. Frontend visibility tricks alone are never enough: RLS policies must enforce the same rules at the database level so a viewer who bypasses the UI still gets a permission-denied response from Postgres.

## Anatomy of the Feature

Six components. Three of them AI tools handle well on the first prompt. The role-update Edge Function and the RLS policies are where builds break without specific instructions.

- **Role assignment layer** (data): A role column (enum: admin, editor, viewer) on the profiles table, plus Supabase RLS policies on every protected table. The enum lives in Postgres; the authoritative copy for auth decisions lives in Supabase auth app_metadata where it is included in every JWT the client receives.
- **Auth session + role claim** (backend): A Supabase Edge Function triggered on user signup injects the default role ('viewer') into app_metadata using the Supabase admin API. The supabase_flutter SDK on the Flutter client reads currentUser.appMetadata['role'] from the decoded JWT after each session refresh — no extra network call needed once authenticated.
- **Permission gate widget** (ui): A custom Flutter PermissionGate widget that wraps any child widget and shows it only if the current user's role matches an allowed list. Reads role from a Riverpod (or Provider) state variable populated at login. Used inline throughout the app to hide buttons, tabs, and actions that a lower-privilege role cannot access.
- **Protected route guard** (ui): A GoRouter redirect callback that checks the user's role from auth state before allowing navigation to admin or editor routes. If the role is insufficient, it redirects to a /unauthorized screen with a clear error message rather than letting the screen render partially or crash.
- **Admin role management screen** (ui): A Flutter screen showing a DataTable or ListView of all users and their current roles, with a dropdown or segmented control to change each user's role. The change action calls the role-update Edge Function — it never calls the Supabase auth admin API directly from the Flutter client.
- **Role-update Edge Function** (backend): A Supabase Edge Function (Deno/TypeScript) that accepts a userId and newRole, validates the caller's JWT to confirm they are an admin, then uses the supabaseAdmin client initialized with SUPABASE_SERVICE_ROLE_KEY to call auth.admin.updateUserById(). The service-role key is stored in Supabase Secrets, never in the Flutter app.

## Data model

Run this in the Supabase SQL editor. It creates the profiles table with the role enum, RLS policies that enforce role-based access on a sample posts table, and the index that makes the admin user-list screen fast:

```sql
-- Role enum
create type public.app_role as enum ('admin', 'editor', 'viewer');

-- Profiles table (one row per auth user)
create table public.profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null unique,
  role public.app_role not null default 'viewer',
  display_name text,
  created_at timestamptz not null default now()
);

alter table public.profiles enable row level security;

-- Users can read their own profile
create policy "Users can view own profile"
  on public.profiles for select
  using (auth.uid() = user_id);

-- Admins can read all profiles (for the user management screen)
create policy "Admins can view all profiles"
  on public.profiles for select
  using (auth.jwt() ->> 'role' = 'admin');

-- Admins can update any profile role
create policy "Admins can update profiles"
  on public.profiles for update
  using (auth.jwt() ->> 'role' = 'admin');

-- Example: posts table with role-based RLS
create table public.posts (
  id uuid primary key default gen_random_uuid(),
  author_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  body text,
  published boolean not null default false,
  created_at timestamptz not null default now()
);

alter table public.posts enable row level security;

-- Viewers and editors and admins can read published posts
create policy "Anyone authenticated can read published posts"
  on public.posts for select
  using (auth.role() = 'authenticated' and published = true);

-- Editors and admins can insert posts
create policy "Editors and admins can insert posts"
  on public.posts for insert
  with check (
    auth.jwt() ->> 'role' = 'editor'
    or auth.jwt() ->> 'role' = 'admin'
  );

-- Editors can update their own posts; admins can update any post
create policy "Editors can update own posts, admins can update any"
  on public.posts for update
  using (
    (auth.jwt() ->> 'role' = 'editor' and auth.uid() = author_id)
    or auth.jwt() ->> 'role' = 'admin'
  );

-- Only admins can delete posts
create policy "Admins can delete posts"
  on public.posts for delete
  using (auth.jwt() ->> 'role' = 'admin');

-- Fast lookup for the admin user-list screen
create index profiles_role_idx on public.profiles (role);
create index profiles_user_id_idx on public.profiles (user_id);
create index posts_author_created_idx on public.posts (author_id, created_at desc);
```

The auth.jwt() ->> 'role' expression reads the role directly from the JWT claim injected by Supabase into app_metadata — this means the RLS check adds zero database round trips. Test every policy via the Supabase Table Editor's RLS simulator before relying on them in production.

## Build paths

### Flutterflow — fit 4/10, 4–8 hours

Best fit for mobile-only RBAC: FlutterFlow's visual Widget Tree lets you add conditional widget visibility based on an App State role variable without writing Dart. The critical limitation is that role enforcement is frontend-only by default — you must separately configure Supabase RLS and a manual Edge Function for server-side enforcement.

1. In Supabase, run the SQL schema from this page in the SQL editor, then create a Supabase Edge Function named 'update-user-role' that accepts userId and newRole, validates the caller's JWT for admin role, and calls supabaseAdmin.auth.admin.updateUserById() using the service-role key stored in Supabase Secrets
2. In FlutterFlow, connect your Supabase project under Settings → Supabase and import the profiles and posts tables
3. Create an App State variable named 'userRole' (String type); in your app's initialization action (on app start), fetch the current user's JWT claims via a Supabase query on profiles where user_id equals the current user's ID and set userRole from the result
4. Wrap any admin-only or editor-only widgets in a Conditional Visibility condition: show only when App State userRole equals 'admin' (or 'editor'); for route-level protection, add a GoRouter-style redirect in your page's On Page Load action that checks userRole and navigates to an /unauthorized page if insufficient
5. Build the admin user-management page as a DataTable listing profiles rows; add a role-change dropdown whose On Changed action calls your 'update-user-role' Edge Function via an API Call action, passing the target userId and selected role
6. Test role enforcement by switching between test accounts with different roles and verify the Supabase Table Editor confirms the correct RLS behavior — remember to also check that a viewer-role user calling your posts Edge Function directly gets a permission-denied response

Limitations:

- Role enforcement in FlutterFlow widget visibility is frontend-only — a determined user can bypass it without the RLS policies you must configure separately in Supabase
- GoRouter-style route guards require custom Dart code via a Custom Action — not available natively in FlutterFlow's visual action editor
- Complex role hierarchies (super-admin inheriting admin permissions) are not possible through FlutterFlow's visual layer alone; they require custom Dart code or complex RLS composition
- The Admin SDK call to update app_metadata cannot be made directly from FlutterFlow actions — you must build an Edge Function and call it via an API call action

### Lovable — fit 2/10, 3–5 hours

Lovable targets web (Vite/React), not Flutter mobile. Use it only if you need a web-based admin panel companion — for example, a separate dashboard where your team manages mobile app users and their roles while the mobile RBAC itself is built with FlutterFlow.

1. Create a new Lovable project and connect Lovable Cloud so Supabase auth and the profiles table are provisioned automatically
2. Paste the prompt below to build the web admin panel for role management
3. In the Lovable Cloud tab, go to Secrets and confirm SUPABASE_SERVICE_ROLE_KEY is set — the Edge Function that updates app_metadata needs it
4. Publish the admin panel and share it with your internal team; keep it behind Lovable's access controls so only admins can open it

Starter prompt:

```
Build a web admin panel for managing user roles in a mobile app. The app has three roles: admin (can do everything including manage users), editor (can create and edit posts but cannot delete users or change roles), and viewer (read-only). Use Supabase for the database. Build these screens: 1) User list page — show all users from a 'profiles' table (user_id, display_name, role, created_at) with their current role displayed as a colored badge (admin = red, editor = blue, viewer = gray). Each row has a role dropdown (admin / editor / viewer) that saves changes. 2) When the admin changes a role, call a Supabase Edge Function named 'update-user-role' passing the target user's ID and the new role — do NOT call the Supabase admin API directly from the client. 3) Add a guard: if only one admin exists and the admin tries to demote that user, show an error toast 'Cannot remove the last admin account' and cancel the change. 4) Show a loading spinner while the role update is in flight and a success toast when it completes. Store the user's own role in Supabase auth app_metadata, not user_metadata. The admin panel itself requires the user to be logged in with role = admin to access — redirect non-admins to a /unauthorized page.
```

Limitations:

- Lovable builds a web React app — it cannot build the Flutter mobile app itself where the RBAC actually runs for end users
- The web admin panel is a companion tool only; the core RBAC mobile experience still requires FlutterFlow or custom Flutter development
- Lovable auto-configures Supabase auth but you must manually create the role-update Edge Function and configure the service-role key in Secrets

### Custom — fit 5/10, 1–2 weeks

The only path that supports complex multi-role hierarchies, role inheritance, attribute-based access control, and compliance audit trails. Uses supabase_flutter + GoRouter + Riverpod as the standard Flutter stack.

1. supabase_flutter SDK for auth + role claim reading from currentUser.appMetadata; Riverpod for a global AuthNotifier that exposes the current role to the entire widget tree
2. GoRouter redirect callbacks on every protected route: check auth state, then role, then navigate to /unauthorized if insufficient — handling the unauthenticated state before the role check prevents null pointer crashes
3. Custom PermissionGate widget that accepts a list of allowed roles and conditionally renders its child — reused everywhere instead of duplicating if/else role checks
4. Supabase Edge Functions (Deno/TypeScript) for all role mutations: update-user-role validates the caller's JWT, checks the last-admin guard, then calls auth.admin.updateUserById() with the service-role key
5. For multi-role or hierarchical requirements: a dedicated permissions table mapping roles to capabilities, or integration with a library like casbin for policy evaluation beyond what flat enum RLS can express

Limitations:

- Requires Dart/Flutter development skills and understanding of Riverpod state management — not beginner-friendly
- Only necessary when role requirements exceed what FlutterFlow + Supabase RLS can express; for the majority of apps with 3 flat roles, FlutterFlow is sufficient

## Gotchas

- **Role change in the UI doesn't persist or affect the target user** — FlutterFlow backend queries use the anon key, which cannot call auth.admin.updateUserById() — that API requires a service-role key. If you wire the role change directly to a Supabase table update or a standard query action, the app_metadata role claim is never updated, so the target user's JWT still contains their old role on every subsequent request. Fix: Create a Supabase Edge Function named 'update-user-role' that accepts userId and newRole, validates the caller's JWT to confirm admin role, then uses a supabaseAdmin client initialized with SUPABASE_SERVICE_ROLE_KEY (stored in Supabase Secrets) to call auth.admin.updateUserById(). Call this function from FlutterFlow using a Custom Action or an API Call action — never update app_metadata from the client directly.
- **User still sees the old role after an admin changes it** — Supabase JWTs are not invalidated or refreshed automatically when app_metadata changes. The target user's Flutter app holds a cached JWT that still contains the old role claim until the token naturally expires (typically 60 minutes). They see the right role in the database but the wrong role in the UI and in every RLS check made with their current token. Fix: After a successful role update, have the Edge Function return a signal to the admin confirming the change. On the target user's side, force a session refresh via supabase.auth.refreshSession() — the cleanest approach is to call this on app resume or after a push notification triggers a refresh. As a fallback, show a banner in the app: 'Your access level was updated — tap to refresh.' Document this 60-minute propagation window clearly in the admin screen.
- **Role check passes in the UI but direct API calls still succeed for lower-privilege users** — FlutterFlow widget visibility conditions and GoRouter guards are frontend enforcement only. If RLS policies are not set on the Supabase tables, any user with a valid JWT can call the Supabase REST API directly and read or write data regardless of role — the Flutter app hiding a button does not prevent the underlying API call. Fix: Add RLS policies to every table before shipping. Use auth.jwt() ->> 'role' in your USING and WITH CHECK clauses to enforce role at the database level. Test each policy using the Supabase Table Editor's RLS simulator with test JWTs for each role before going live — policies that look correct often have subtle gaps (wrong table, missing FOR clause) that only appear under simulation.
- **App crashes on startup with null pointer reading role from appMetadata** — New users created before the role assignment Edge Function was added have no role key in app_metadata at all — the field is absent, not null. Reading currentUser.appMetadata['role'] without a null-safe fallback throws a null pointer exception. The same crash occurs if the GoRouter redirect reads role before the auth session is fully initialized. Fix: Use null-safe access everywhere: role = currentUser?.appMetadata?['role'] as String? ?? 'viewer'. In GoRouter, check auth state (is the user logged in at all?) before checking role — if auth state is loading, show a splash screen instead of redirecting. Backfill existing users with a default viewer role in Supabase by running an update against auth.users joined to profiles for any user missing the role claim.
- **Admin accidentally demotes the last admin, locking everyone out** — Without a guard check, an admin who demotes themselves (or the only other admin) leaves the app with zero admin accounts. No one can promote users back to admin without direct database access — and even then, app_metadata requires the service-role key, so most non-technical teams are fully locked out. Fix: In the role-update Edge Function, before executing the update, count the current admin rows: SELECT COUNT(*) FROM profiles WHERE role = 'admin'. If the count is 1 and the target user is that admin, return HTTP 403 with the message 'Cannot remove the last admin account'. Surface this error message in the FlutterFlow UI as a toast or dialog so the admin understands why the change was blocked.

## Best practices

- Always store roles in Supabase auth app_metadata, never in user_metadata or a user-editable profile field — app_metadata requires the service-role key to modify and cannot be self-assigned by the user
- Enforce roles at the database level with Supabase RLS policies using auth.jwt() ->> 'role' — UI visibility conditions are a UX convenience, not a security boundary
- Build a single reusable PermissionGate widget rather than scattering if/else role checks throughout your widget tree — changing permission logic in one place is far safer than hunting through 50 screens
- Protect the last admin: add a guard in your role-update Edge Function that returns an error if the operation would reduce admin count to zero
- Force a session refresh after a role update using supabase.auth.refreshSession() and communicate the expected propagation delay clearly in your admin UI — a 60-minute stale role is a support ticket waiting to happen
- Never expose SUPABASE_SERVICE_ROLE_KEY in a mobile app binary — always call the admin API from an Edge Function and pass only the anon key to the Flutter client
- Test every RLS policy using the Supabase Table Editor's RLS simulator with a JWT for each role before shipping — a missing WITH CHECK clause on an INSERT policy is invisible until a viewer creates their first record
- Add null-safe fallbacks for every role read: treat a missing or unrecognized role as 'viewer' rather than allowing an uncaught exception that crashes the app or, worse, grants elevated access

## Frequently asked questions

### What is the difference between storing roles in app_metadata versus user_metadata in Supabase?

app_metadata can only be written using the Supabase service-role key — meaning only your server-side code (an Edge Function) can change it. user_metadata can be written by the user themselves from the client using supabase.auth.updateUser(). For role storage, always use app_metadata: if a user can change their own role, the entire permission system is compromised.

### Can FlutterFlow enforce roles server-side without custom code?

No. FlutterFlow's conditional widget visibility and page conditionals are pure frontend — they hide UI elements but do not restrict API access. Server-side enforcement requires Supabase RLS policies on your database tables. You configure those in the Supabase SQL editor directly, not through FlutterFlow. You also need a manually created Edge Function to safely update app_metadata when an admin changes a role — FlutterFlow has no native action for this.

### How do I prevent a user from seeing admin screens even if they guess the route URL?

Add a GoRouter redirect callback that runs before the admin screen renders. It reads the user's role from the current auth session and redirects to an /unauthorized screen if the role is insufficient. On top of that, ensure your Supabase RLS policies block the underlying data queries — even if a user somehow rendered the admin screen, they would get empty results or permission-denied errors from the database.

### What happens to a user's session when their role is changed by an admin?

Nothing happens automatically. The user's current JWT still contains the old role until it expires (typically after 60 minutes) or until they trigger a session refresh via supabase.auth.refreshSession(). This is a known limitation of JWT-based auth. The practical fix is to call refreshSession() on app resume and to communicate in your admin UI that role changes can take up to 60 minutes to fully propagate without a forced refresh.

### Can a user have multiple roles at the same time?

Not with a simple enum column in app_metadata. If you need multi-role support (a user who is both 'editor' and 'billing-manager'), you need a separate user_roles junction table (user_id, role) and RLS policies that check for membership in that table rather than a single claim. This is significantly more complex to implement and is usually a sign that a custom development engagement is the right path.

### How do I test that my Supabase RLS policies actually block unauthorized access?

Use the Supabase Table Editor's built-in RLS policy simulator. Select a table, click the shield icon, and enter a test JWT payload with a specific role claim. The simulator shows exactly which rows a user with that JWT can select, insert, update, or delete. Run a simulation for every role (admin, editor, viewer) against every table with RLS enabled before you ship — a missing WITH CHECK clause on INSERT is one of the most common gaps.

### Is role-based access control enough for HIPAA or SOC 2 compliance?

RBAC is a required component of both frameworks but not sufficient alone. HIPAA and SOC 2 also require audit logging of who accessed what data and when, minimum necessary access principles, and documented access review processes. Supabase does not provide built-in audit trails for auth events. If compliance is a hard requirement, plan for a dedicated audit_logs table with immutable append-only RLS, or a third-party log management service, from the start.

### How do I add a new role without breaking existing users?

Add the new value to your Postgres enum with ALTER TYPE app_role ADD VALUE 'new-role' — this is non-destructive and does not affect existing rows. Then add the corresponding RLS policy clauses for the new role on every affected table. Backfill any users who should receive the new role by running an update via a service-role Edge Function. Existing users without the new role continue to function exactly as before.

---

Source: https://www.rapidevelopers.com/app-features/role-based-access-control
© RapidDev — https://www.rapidevelopers.com/app-features/role-based-access-control
