Security10 min read
Flutter Role-Based Access Control Guide
Add role-based access control to your Flutter app: define roles and permissions, gate screens and actions, protect routes, and enforce all rules on the server.
- #flutter
- #role-based-access
- #authentication
- #authorization
- #security

When I built the Muntazim Parent App, a school management app for parents, one requirement shaped the whole architecture: a parent must see only their own child's attendance, grades and fee status, never another family's data. Teachers see their classes, school admins see everything, and parents see exactly one slice. That boundary is role-based access control, and getting it wrong means showing one family another family's private information. Role-based access control (RBAC) is the pattern of grouping permissions into roles and checking those permissions at every layer of the app: the UI hides what a role cannot use, the router blocks routes a role cannot open, and the server rejects requests a role cannot make. This guide shows how to build that in Flutter, using the same layering I used in Muntazim, where I implemented secure authentication and role-based access for parents. You can see the finished app in the Muntazim case study.
What is role-based access control in Flutter?
Role-based access control in Flutter is a way of deciding what each user can see and do by assigning them a role, such as parent, teacher or admin, and attaching a fixed set of permissions to each role. A permission is a single capability, like viewing a child's grades, recording attendance, sending an announcement or managing fee payments, and a role is a named bundle of those permissions. The app checks permissions, not roles, wherever a decision is made: a button asks whether the user can edit grades, not whether the user is a teacher. That distinction matters because roles change, a teacher can also be a parent, while permissions stay stable. Checking roles directly scatters hard-coded role names through the codebase and breaks the moment someone holds two roles. Checking permissions keeps every decision in one place: the role-to-permission map. The rest of this guide builds that map and enforces it in the UI, the router and the server. In the school app used as the running example here, the roles break down like this:
| Role | Can do |
|---|---|
| Parent | View own child's attendance, grades and fees; receive announcements |
| Teacher | View own classes; record attendance; enter grades; send class announcements |
| Admin | Manage users and fees; send school-wide announcements; view all records |
Model roles and permissions as data
Start by modelling roles and permissions as plain Dart data, before any widget or route checks them. Two enums carry the whole system: AppRole for the roles and Permission for the capabilities. A single map connects them, and it is the only place in the app that knows which role can do what. The user model holds a list of roles, not a single role, and exposes a can() method so every check reads the same way:
enum AppRole { parent, teacher, admin }
enum Permission {
viewOwnChild,
viewClass,
recordAttendance,
editGrades,
sendAnnouncement,
manageFees,
manageUsers,
}
const Map<AppRole, Set<Permission>> rolePermissions = {
AppRole.parent: {
Permission.viewOwnChild,
},
AppRole.teacher: {
Permission.viewClass,
Permission.recordAttendance,
Permission.editGrades,
Permission.sendAnnouncement,
},
AppRole.admin: {
Permission.viewClass,
Permission.recordAttendance,
Permission.editGrades,
Permission.sendAnnouncement,
Permission.manageFees,
Permission.manageUsers,
},
};
class AppUser {
const AppUser({required this.id, required this.roles});
final String id;
final List<AppRole> roles;
Set<Permission> get permissions =>
roles.expand((role) => rolePermissions[role] ?? {}).toSet();
bool can(Permission permission) => permissions.contains(permission);
}
A parent who is also a teacher gets the union of both roles' permissions, with no special-casing anywhere else. When the school adds a role, you add one enum value and one map entry, and every screen, route and server rule picks it up. If you are setting up sign-in first, the Flutter Firebase authentication guide covers email, Google and phone auth end to end.
Gate the UI by role, but never trust the UI alone
The UI is where role checks are most visible, and it is also the layer where they matter least for security. Hiding a button stops an honest user from tapping it; it does nothing against a modified client or a direct API call. Gate the UI anyway, because a parent who never sees the fee-management screen cannot tap it by accident, and the interface stays clean for every role. Just treat UI gating as usability, not security, and enforce the same permission again on the server. Keep the current user in a small session object, provided through Provider, with one can() method that the whole widget tree shares:
class UserSession extends ChangeNotifier {
AppUser? _user;
AppUser? get user => _user;
bool get isSignedIn => _user != null;
bool can(Permission permission) => _user?.can(permission) ?? false;
void signIn(AppUser user) {
_user = user;
notifyListeners();
}
void updateUser(AppUser user) {
_user = user;
notifyListeners();
}
void signOut() {
_user = null;
notifyListeners();
}
}
Then wrap role-restricted UI in a tiny RoleGate widget instead of scattering if statements through every screen:
class RoleGate extends StatelessWidget {
const RoleGate({
super.key,
required this.permission,
required this.child,
this.fallback = const SizedBox.shrink(),
});
final Permission permission;
final Widget child;
final Widget fallback;
@override
Widget build(BuildContext context) {
final session = context.watch<UserSession>();
return session.can(permission) ? child : fallback;
}
}
Usage reads plainly at the call site:
RoleGate(
permission: Permission.manageFees,
child: ElevatedButton(
onPressed: _openFeeManagement,
child: const Text('Manage fees'),
),
)
One judgement call remains: hide or disable. Hide actions a role will never have, and disable with a short explanation the actions a role might gain later, so users understand the boundary instead of wondering where the button went.
Protect routes with go_router redirects
UI gating hides buttons, but a user can still reach a screen through a deep link, a bookmarked route or a typed URL on Flutter web. Route protection closes that gap: the router checks the permission before the page builds, and sends an unauthorized user somewhere safe. In go_router that check lives in the redirect callback, which runs on every navigation. Pass the session as refreshListenable so the redirect re-runs whenever the session changes, not just when the user navigates:
final session = UserSession();
final router = GoRouter(
refreshListenable: session,
redirect: (context, state) {
final location = state.matchedLocation;
if (!session.isSignedIn && location != '/sign-in') {
return '/sign-in';
}
if (location.startsWith('/admin') &&
!session.can(Permission.manageUsers)) {
return '/home';
}
if (location.startsWith('/fees') &&
!session.can(Permission.manageFees)) {
return '/home';
}
return null;
},
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/fees',
builder: (context, state) => const FeesScreen(),
),
GoRoute(
path: '/admin',
builder: (context, state) => const AdminScreen(),
),
],
);
Returning null lets the navigation proceed; returning a path reroutes the user. Keep the redirect purely about access, not loading data or showing dialogs, because it runs often and must stay fast. Provide the same session instance to the widget tree with ChangeNotifierProvider.value, so the router and the UI always agree about who the user is. For the full navigation setup, see the go_router guide.
Enforce every rule on the server
Everything above runs on the user's device, which means a determined user can bypass it. The server is the only layer you control, so it is the only layer that can say no. Every request that reads or writes protected data must be checked against the caller's roles on the server, using the same role-to-permission map. The client checks exist for a good experience; the server checks exist for security. With Firestore, that means security rules that mirror the client map, including a parent rule that only matches their own child's document:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function roles() {
return get(/databases/$(database)/documents/users/$(request.auth.uid))
.data.roles;
}
function hasRole(role) {
return request.auth != null && role in roles();
}
match /students/{studentId} {
allow read: if hasRole('admin')
|| hasRole('teacher')
|| request.auth.uid in resource.data.parentIds;
allow write: if hasRole('admin') || hasRole('teacher');
}
match /users/{userId} {
allow read: if request.auth.uid == userId || hasRole('admin');
allow update: if hasRole('admin');
}
}
}
The users rule matters as much as the students rule: roles live in the user document, so if users can edit their own document they can grant themselves admin and walk through every other rule. Only admins may write the roles field. The same principle applies to a REST backend like the one behind Muntazim: verify the auth token on the server, load the caller's roles from the database, and return 403 before touching any data. The client sends the token and the server decides:
final token = await FirebaseAuth.instance.currentUser?.getIdToken();
final response = await http.get(
Uri.parse('https://api.example.com/children/abc123/grades'),
headers: {'Authorization': 'Bearer ' + (token ?? '')},
);
If the token is missing or the role check fails, the server answers 401 or 403 and the client shows a clear message. Test the server rules with the Firebase emulator or a staging backend before shipping; a wrong rule in production either leaks data or locks out every parent at the school gate.
Handle role changes while the app is running
Roles are not permanent. A school promotes a teacher to admin, or removes a parent's access when a child leaves. If the app caches the roles from sign-in and never refreshes them, the old permissions stay live until the next login, which can be days. Stream the user document instead, and update the session on every snapshot, so a role change takes effect while the app is open:
Stream<AppUser> watchUser(String userId) {
return FirebaseFirestore.instance
.collection('users')
.doc(userId)
.snapshots()
.map((doc) {
final data = doc.data() ?? {};
final roles = (data['roles'] as List? ?? [])
.whereType<String>()
.where((name) =>
AppRole.values.any((role) => role.name == name))
.map(AppRole.values.byName)
.toList();
return AppUser(id: doc.id, roles: roles);
});
}
Filter out unknown role strings before calling byName, because byName throws on a name it does not recognise, and one bad value should not crash the app. Subscribe in the session, calling updateUser on each snapshot, and the router's refreshListenable does the rest: a demoted admin is redirected out of /admin the moment the snapshot arrives. Real-time updates flow through the same listeners in Muntazim, whether the payload is a role change or a school announcement; the Firebase push notifications guide covers the notification side in detail.
Mistakes that break role-based access
Most role-based access failures come from the same few shortcuts, and each one has a boring fix:
| Mistake | What happens | Fix |
|---|---|---|
| Checking roles instead of permissions | Users with two roles break, and role names spread through the code | Check Permission values from one role map |
| Gating only the UI | Hidden screens still open through deep links or direct API calls | Protect routes and enforce the same rule on the server |
| Letting users edit their own roles | Anyone grants themselves admin | Role fields are writable by admins only |
| Caching roles at sign-in forever | Revoked access stays live until the next login | Stream the user document and refresh the session |
| One role per user | A teacher who is also a parent needs two accounts | Store a list of roles and union the permissions |
Putting it together in Muntazim
In Muntazim, these layers stack the same way. Parents sign in with secure authentication, the app loads their roles, the interface shows only their own child's attendance, grades and fees, routes outside their role redirect away, notifications keep them posted on announcements, and the backend re-checks every request. No single layer carries the security; the layers agree with each other, so a failure in one is caught by the next. Role-based access control is not a widget or a rule, it is the same permission check repeated at every boundary a user can cross. If your app has more than one kind of user, add roles before the second user type ships. Retrofitting permissions across screens, routes and a backend costs far more than modelling them on day one. Get in touch and let's talk about your project.
Frequently asked questions
- What is role-based access control in Flutter?
- Role-based access control means assigning each user a role, such as parent, teacher or admin, and attaching a fixed set of permissions to each role. The app then checks permissions at every layer: the UI hides what a role cannot use, the router blocks routes a role cannot open, and the server rejects requests a role cannot make.
- Should I check roles or permissions in my Flutter widgets?
- Check permissions. A teacher can also be a parent, and checking roles directly spreads hard-coded role names through the codebase. One role-to-permission map keeps every decision in a single place and keeps working when a user holds more than one role.
- Is hiding a button enough to secure a feature in Flutter?
- No. Hiding a button only stops an honest user from tapping it. A modified client, a deep link or a direct API call can still reach the feature, so protect the route with a router redirect and enforce the same permission on the server.
- How do I handle a user whose role changes while the app is running?
- Stream the user document instead of caching roles at sign-in, and update the session on every snapshot. With the session wired as the router refreshListenable, a demoted user is redirected out of restricted routes immediately.


