Feature spec
IntermediateCategory
auth-security
Build with AI
4–8 hours with FlutterFlow + Supabase
Custom build
1–2 weeks custom dev
Running cost
$0–25/mo up to 10K users
Works on
Everything it takes to ship Role-Based Access Control — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Roles are assigned at registration or by an admin — users cannot self-assign or elevate their own role
- Role changes take effect for the target user without requiring them to log out and back in
- Attempting to navigate to a protected screen redirects to a clear unauthorized screen, not a blank page or a crash
- Admins can see a user management screen listing all users with their current roles and promote or demote from there
- Permissions are enforced server-side by Supabase RLS — not just hidden in the mobile UI
- The user's role is visible in their profile screen as a badge or label so they know what they can do
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
DataA 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.
Note: Store the role in app_metadata, not user_metadata. user_metadata can be written by the user from the client; app_metadata requires the service-role key and can only be modified server-side.
Auth session + role claim
BackendA 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.
Note: The role claim is embedded in the JWT, so it is available instantly on app start without a database query. The trade-off: it is stale until the next token refresh (typically every 60 minutes) unless you force a refresh after an admin changes a role.
Permission gate widget
UIA 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.
Note: This is a UX convenience layer, not a security layer. Never rely on it alone — the API and RLS policies are the real enforcement.
Protected route guard
UIA 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.
Note: Must handle the unauthenticated state before the role check — reading appMetadata on a null user throws a null pointer exception.
Admin role management screen
UIA 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.
Note: Prevent the last admin from demoting themselves — the Edge Function should count current admins before allowing the update and return a 403 if only one admin remains.
Role-update Edge Function
BackendA 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.
Note: This is the only safe way to modify app_metadata. Using the anon key or calling the admin API from the Flutter client directly will fail — both because of missing permissions and because exposing a service-role key in a mobile app is a critical security vulnerability.
The 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:
1-- Role enum2create type public.app_role as enum ('admin', 'editor', 'viewer');34-- Profiles table (one row per auth user)5create table public.profiles (6 id uuid primary key default gen_random_uuid(),7 user_id uuid references auth.users(id) on delete cascade not null unique,8 role public.app_role not null default 'viewer',9 display_name text,10 created_at timestamptz not null default now()11);1213alter table public.profiles enable row level security;1415-- Users can read their own profile16create policy "Users can view own profile"17 on public.profiles for select18 using (auth.uid() = user_id);1920-- Admins can read all profiles (for the user management screen)21create policy "Admins can view all profiles"22 on public.profiles for select23 using (auth.jwt() ->> 'role' = 'admin');2425-- Admins can update any profile role26create policy "Admins can update profiles"27 on public.profiles for update28 using (auth.jwt() ->> 'role' = 'admin');2930-- Example: posts table with role-based RLS31create table public.posts (32 id uuid primary key default gen_random_uuid(),33 author_id uuid references auth.users(id) on delete cascade not null,34 title text not null,35 body text,36 published boolean not null default false,37 created_at timestamptz not null default now()38);3940alter table public.posts enable row level security;4142-- Viewers and editors and admins can read published posts43create policy "Anyone authenticated can read published posts"44 on public.posts for select45 using (auth.role() = 'authenticated' and published = true);4647-- Editors and admins can insert posts48create policy "Editors and admins can insert posts"49 on public.posts for insert50 with check (51 auth.jwt() ->> 'role' = 'editor'52 or auth.jwt() ->> 'role' = 'admin'53 );5455-- Editors can update their own posts; admins can update any post56create policy "Editors can update own posts, admins can update any"57 on public.posts for update58 using (59 (auth.jwt() ->> 'role' = 'editor' and auth.uid() = author_id)60 or auth.jwt() ->> 'role' = 'admin'61 );6263-- Only admins can delete posts64create policy "Admins can delete posts"65 on public.posts for delete66 using (auth.jwt() ->> 'role' = 'admin');6768-- Fast lookup for the admin user-list screen69create index profiles_role_idx on public.profiles (role);70create index profiles_user_id_idx on public.profiles (user_id);71create index posts_author_created_idx on public.posts (author_id, created_at desc);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1supabase_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
- 2GoRouter 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
- 3Custom PermissionGate widget that accepts a list of allowed roles and conditionally renders its child — reused everywhere instead of duplicating if/else role checks
- 4Supabase 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
- 5For 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
Where this path bites
- 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
Third-party services you'll need
Role-based access control in a mobile app needs only Supabase — no third-party auth service required. All three Supabase tiers involved are on the same subscription:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Auth | JWT-based authentication with app_metadata for tamper-proof role claims embedded in every token | Up to 50,000 MAU on Free tier | Pro at $25/mo (up to 100,000 MAU) |
| Supabase Database | PostgreSQL with RLS policies enforcing role-based access at the database level on every protected table | 500 MB on Free tier | Pro at $25/mo (8 GB, shared with Auth subscription) |
| Supabase Edge Functions | Deno serverless functions for role-update logic — the only safe way to call the admin API with the service-role key | 500,000 invocations/month on Free tier | Included in Pro $25/mo (2M invocations/mo) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase Free tier covers auth (50K MAU limit), database (500MB), and Edge Function invocations (500K/mo) comfortably. No third-party services needed for RBAC itself.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Role change in the UI doesn't persist or affect the target user
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
FlutterFlow + Supabase RLS handles the 90% case: three flat roles, per-table access rules, and a simple admin panel. Custom development is the right call when your permission model grows beyond what enum roles and RLS policies can cleanly express:
- You need role inheritance or hierarchical permissions — for example, 'super-admin' inherits all 'admin' capabilities plus billing access — flat enum roles in app_metadata do not support inheritance without custom logic or a library like casbin
- You need attribute-based access control (ABAC) where access depends on both role and resource ownership — for example, an editor can edit their own posts but not other editors' posts — this requires complex RLS policy composition that becomes difficult to audit and maintain past 3–4 rules
- You need a compliance audit trail of every permission check and every role change for SOC 2 or HIPAA — Supabase does not provide built-in audit logging for auth events; a dedicated audit log table with immutable append-only policies and third-party log shipping is required
- Your app has more than 5 distinct roles with overlapping and conflicting permission sets — managing this purely through RLS policies becomes unmaintainable and difficult to test exhaustively; a dedicated permissions service is the correct architectural layer
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds role-based access control into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.