
Imagine you've built a Canvas App for your operations team. There's a dashboard for managers to approve purchase orders, a data entry form for warehouse staff, and an admin panel for IT to manage lookup tables. Right now, every user who opens the app sees every screen. Your warehouse staff can stumble into the admin panel. Your managers can accidentally submit a data entry form meant for floor workers. And IT keeps getting questions about why they're seeing a pending approvals queue that has nothing to do with them.
The naive solution is to build separate apps — one per role. But that creates a maintenance nightmare. Every time the data model changes, you're updating three apps instead of one. The better solution is to implement role-based access control (RBAC) directly inside a single Canvas App, using the Azure Active Directory (AD) groups that your organization already has set up. When a user opens the app, it detects their group memberships, sets their role, and dynamically shows them only the screens, buttons, and data they're supposed to see.
By the end of this lesson, you'll know how to do exactly that — from querying AD group membership at app launch, to controlling screen navigation, to conditionally rendering UI components based on role. You'll build a realistic multi-role operations app that you can use as a template for production work.
What you'll learn:
Office365Groups connector and MicrosoftEntra (formerly Azure AD) connector to check group membership at runtimeYou should already be comfortable with:
OnStart and OnVisible eventsFilter, If, Set, and CollectYou'll need:
Before touching the app, let's be clear about what we're building and why it works the way it does.
Azure AD groups are the source of truth for roles. Your IT or security team manages group membership — they add and remove people from groups as employees change roles. Your Canvas App doesn't manage permissions; it reads them. This is the right separation of concerns. If someone is promoted from warehouse staff to manager, IT updates their AD group, and the next time they open the app, it automatically reflects their new role. You don't touch the app.
The mechanism looks like this: when the app starts, it calls a connector that checks whether the currently signed-in user (User().Email) is a member of specific AD groups. The result of those checks gets stored in app-level variables. Every screen, every button, every gallery then reads those variables — not the AD groups directly. This is critical for performance. If every control made its own live AD query, your app would be unusably slow. One query per group at startup, results cached in variables, everything else reads the variables.
Here's the role model we'll use throughout this lesson:
| Role | Azure AD Group Name | Capabilities |
|---|---|---|
| Warehouse Staff | ops-warehouse-staff |
Submit purchase requests, view own requests |
| Operations Manager | ops-managers |
Approve/reject requests, view all requests, basic reporting |
| IT Admin | ops-it-admins |
Everything above + manage lookup tables, app configuration |
A user might be in multiple groups (an IT admin who also approves things, for example). We'll handle that case explicitly.
You have two realistic options for querying AD group membership:
Option 1: Office365Groups connector — Available to most makers, queries Microsoft 365 Groups. Works if your organization uses M365 groups for role management.
Option 2: Microsoft Entra connector (formerly Azure AD connector) — Queries security groups as well as M365 groups. More powerful, but may require admin approval in your environment.
For most enterprise scenarios, you'll want the Microsoft Entra connector because your IT team likely manages roles using security groups, not M365 groups. If your environment restricts it, the Office365Groups connector works for M365 groups.
To add the connector in Power Apps Studio:
Once added, you'll see MicrosoftEntra appear in your data sources list.
Important: The connector queries on behalf of the signed-in user. It does NOT give users access to group management — it only checks membership. The signed-in user sees only what AD would tell them about their own memberships.
The MicrosoftEntra connector identifies groups by their Object ID, not their display name. Display names can change; Object IDs are permanent. You'll need these IDs before writing your formulas.
To find a group's Object ID:
ops-managers)Write these down — you'll be embedding them in your app formulas. For our example, let's say:
ops-warehouse-staff → a1b2c3d4-1111-2222-3333-aabbccdd0001
ops-managers → a1b2c3d4-1111-2222-3333-aabbccdd0002
ops-it-admins → a1b2c3d4-1111-2222-3333-aabbccdd0003
The App.OnStart property is the right place for role resolution. It runs once when the app loads, before any screen is shown. This is where we make our AD calls and store the results.
Here's the complete OnStart formula for our operations app:
// Step 1: Resolve group membership for each role
// IsMemberOf returns a record with a 'value' boolean field
Set(
varIsWarehouseStaff,
MicrosoftEntra.IsMemberOf(
"a1b2c3d4-1111-2222-3333-aabbccdd0001",
User().Email
).value
);
Set(
varIsManager,
MicrosoftEntra.IsMemberOf(
"a1b2c3d4-1111-2222-3333-aabbccdd0002",
User().Email
).value
);
Set(
varIsITAdmin,
MicrosoftEntra.IsMemberOf(
"a1b2c3d4-1111-2222-3333-aabbccdd0003",
User().Email
).value
);
// Step 2: Derive a primary role string for convenience
// Priority order: IT Admin > Manager > Warehouse Staff > None
Set(
varUserRole,
If(
varIsITAdmin, "ITAdmin",
varIsManager, "Manager",
varIsWarehouseStaff, "WarehouseStaff",
"NoRole"
)
);
// Step 3: Navigate to the appropriate landing screen
Switch(
varUserRole,
"ITAdmin", Navigate(scrAdminDashboard, ScreenTransition.None),
"Manager", Navigate(scrManagerDashboard, ScreenTransition.None),
"WarehouseStaff", Navigate(scrStaffPortal, ScreenTransition.None),
Navigate(scrAccessDenied, ScreenTransition.None)
)
Let's unpack what's happening:
IsMemberOf returns a record, not a boolean directly. The .value at the end extracts the boolean true/false. If you forget .value, your variable holds a record object, and your If conditions will behave unexpectedly — always evaluating as truthy because a non-blank record is truthy.
The priority order matters. An IT admin who's also in the managers group will get varUserRole = "ITAdmin". The varIsManager and varIsITAdmin variables are both true, but varUserRole reflects the highest privilege. This is intentional — you want your most-privileged role to take precedence for navigation. But notice that we keep all three boolean variables. That's because some features might be available to both managers AND IT admins, and checking varIsManager || varIsITAdmin is cleaner than trying to enumerate roles from a single string.
"NoRole" is a real state you must handle. Users who open your app but aren't in any of the expected groups get routed to scrAccessDenied. This screen should explain the situation clearly and provide a path forward — typically a link to request access or contact IT. Do not just show a blank screen.
Tip: During development, add a temporary label to your first screen with the text
varUserRole & " | " & varIsManager & " | " & varIsITAdmin. This lets you see role resolution working in real time without checking variables in the monitor. Remove it before publishing.
With role resolution working, let's think about how screens map to roles. For our operations app, we'll use this structure:
scrStaffPortal — Staff landing page, request submission form, own request historyscrManagerDashboard — Approval queue, all requests view, team summary reportscrAdminDashboard — Everything in Manager view + lookup table management + app settingsscrAccessDenied — Clean "you don't have access" screenscrShared_RequestDetail — Individual request detail, accessible to all roles (with different action buttons per role)Screens that belong to a single role are straightforward. The interesting cases are shared screens — like scrShared_RequestDetail — where all roles can navigate to the same screen but see different controls.
App.OnStart routes users to their correct landing screen. But navigation control doesn't stop there. Users can (in theory) navigate anywhere if you expose buttons that call Navigate(). You need to ensure that the navigation options available to each user only point to screens they're authorized to use.
The primary mechanism is simple: don't show unauthorized navigation buttons.
In your manager dashboard's navigation menu, you might have a "Manage Lookup Tables" button that leads to scrAdminLookupTables. Set its Visible property to:
varIsITAdmin
That's it. If varIsITAdmin is false, the button doesn't render. The user doesn't know the screen exists.
But here's the problem: Canvas Apps don't enforce screen-level URL restrictions the way web apps do. A determined user who knows the screen name can't navigate to it through the URL (Canvas Apps don't expose screen URLs), but within your app, if you ever programmatically navigate them to a screen — even by accident — there's no automatic gate.
This is why you add a secondary defense on the screen itself using OnVisible.
On every restricted screen, add an OnVisible formula that verifies the user is authorized before the screen fully renders. For scrAdminLookupTables:
If(
Not(varIsITAdmin),
Navigate(scrAccessDenied, ScreenTransition.None)
)
This fires every time the screen becomes visible. If somehow a non-IT-admin lands there — through a bug in your navigation logic, a copy-paste error in a Navigate() call, anything — they immediately get redirected. Think of it as a seatbelt: you hope you don't need it, but it's there.
For screens shared between managers and IT admins:
If(
Not(varIsManager) && Not(varIsITAdmin),
Navigate(scrAccessDenied, ScreenTransition.None)
)
Or more cleanly:
If(
varUserRole = "WarehouseStaff" || varUserRole = "NoRole",
Navigate(scrAccessDenied, ScreenTransition.None)
)
Warning: Do not put sensitive data in a screen's
OnVisibleand then try to hide it after an unauthorized check. The data might flash briefly before navigation fires. Instead, make the gallery or data component itself conditional on role, independent of theOnVisibleredirect. Defense in depth.
The scrShared_RequestDetail screen is where the real UI dynamism happens. Every role can reach this screen (via their respective request lists), but what they can do there is different.
Imagine this screen shows a purchase request with the following potential actions:
Here's how you structure the action button row:
"Edit Request" button — Only visible to warehouse staff, only for their own requests, only when status is "Draft"
Visible = varIsWarehouseStaff
&& galRequests.Selected.SubmittedBy = User().Email
&& galRequests.Selected.Status = "Draft"
"Withdraw Request" button — Same conditions as Edit
Visible = varIsWarehouseStaff
&& galRequests.Selected.SubmittedBy = User().Email
&& galRequests.Selected.Status = "Draft"
"Approve" button — Visible to managers and IT admins, only for submitted requests
Visible = (varIsManager || varIsITAdmin)
&& galRequests.Selected.Status = "Submitted"
"Reject" button — Same as Approve
Visible = (varIsManager || varIsITAdmin)
&& galRequests.Selected.Status = "Submitted"
"Override Status" button — IT admins only, always visible when viewing any request
Visible = varIsITAdmin
Notice the pattern: each button's Visible property combines role variables with data state. You're not just hiding buttons by role — you're making them contextually aware. A manager shouldn't see an "Approve" button on a request that's already approved. A staff member shouldn't see "Edit" on someone else's request.
Beyond buttons, the actual information displayed can vary by role. Managers and admins might see the full financial breakdown of a request, while staff only see a summary. You handle this with conditional visibility on container controls.
Group the "financial detail" section into a named container — let's call it ctnFinancialDetail. Set its Visible property:
Visible = varIsManager || varIsITAdmin
For the staff view, you'd have a separate ctnRequestSummary container:
Visible = varIsWarehouseStaff
Tip: Use containers aggressively for role-based UI sections. Toggling visibility on one container is far more maintainable than toggling visibility on twenty individual controls. When your UI changes, you update the container's contents, not the visibility logic on every child control.
Most serious apps have a navigation menu — either a top bar or a side panel. In a role-aware app, this menu should only show destinations the current user can access.
One clean approach is to build the navigation menu from a collection that's populated based on role during OnStart. This way, a single gallery control renders the correct menu for every role.
Add this to your OnStart after role resolution:
// Build navigation menu based on role
Clear(colNavItems);
// All roles get a home/portal link
Collect(colNavItems, {
Label: "My Portal",
Screen: "scrStaffPortal",
Icon: "Home",
SortOrder: 1
});
If(varIsManager || varIsITAdmin,
Collect(colNavItems, {
Label: "Approval Queue",
Screen: "scrManagerDashboard",
Icon: "TaskList",
SortOrder: 2
});
Collect(colNavItems, {
Label: "All Requests",
Screen: "scrManagerDashboard",
Icon: "DocumentSet",
SortOrder: 3
})
);
If(varIsITAdmin,
Collect(colNavItems, {
Label: "Lookup Tables",
Screen: "scrAdminLookupTables",
Icon: "Settings",
SortOrder: 4
});
Collect(colNavItems, {
Label: "App Configuration",
Screen: "scrAdminConfig",
Icon: "Shield",
SortOrder: 5
})
);
// Sort for consistent ordering
SortByColumns(colNavItems, "SortOrder", Ascending)
Your navigation gallery then binds to colNavItems. Each item's label, icon, and Navigate() target comes from the collection. No role-checking formulas scattered across individual buttons — the menu is authoritative and built once.
Note on the
Screenfield: Canvas Apps don't support dynamicNavigate()calls with screen names stored as strings natively. You'll need to use aSwitchorIfin the gallery item'sOnSelectto map the string to an actual screen reference. This is a known limitation. TheScreencolumn is used as a key, not passed directly toNavigate().
// Gallery item OnSelect
Switch(
ThisItem.Screen,
"scrStaffPortal", Navigate(scrStaffPortal),
"scrManagerDashboard", Navigate(scrManagerDashboard),
"scrAdminLookupTables", Navigate(scrAdminLookupTables),
"scrAdminConfig", Navigate(scrAdminConfig)
)
It's verbose, but it works reliably.
The approach we've built makes 3 AD calls at startup (one per group). Each call is a network round-trip to Microsoft's Graph API under the hood. On a fast corporate network, this is typically 200–500ms total. On a slow connection or with many groups to check, it can add up.
If you have 5+ roles to check, consider whether you can batch the check differently. The MicrosoftEntra.GetMemberGroups() function returns all group memberships for a user as a collection. You can then check membership locally using Filter() instead of making multiple IsMemberOf() calls:
// Single call to get all user's group memberships
ClearCollect(
colUserGroups,
MicrosoftEntra.GetMemberGroups(User().Email, false).value
);
// Local membership checks — no additional network calls
Set(varIsWarehouseStaff,
!IsEmpty(Filter(colUserGroups, Value = "a1b2c3d4-1111-2222-3333-aabbccdd0001"))
);
Set(varIsManager,
!IsEmpty(Filter(colUserGroups, Value = "a1b2c3d4-1111-2222-3333-aabbccdd0002"))
);
Set(varIsITAdmin,
!IsEmpty(Filter(colUserGroups, Value = "a1b2c3d4-1111-2222-3333-aabbccdd0003"))
);
This trades one multi-group call for multiple individual calls. For 2–3 groups, IsMemberOf is simpler and fine. For 6+ groups, GetMemberGroups is faster because it's one round-trip regardless of how many groups you check.
Also consider: App.OnStart blocks the app from showing any screen until it completes. Long-running OnStart formulas create a blank loading screen that frustrates users. If your role resolution is taking more than a second, show a loading screen explicitly:
Set up a scrLoading screen as your initial screen (set in App properties). It shows a spinner and the message "Loading your workspace..." and has no OnVisible logic. Your OnStart runs role resolution, then navigates away from scrLoading to the appropriate role screen when done. The user sees the spinner instead of a blank white screen.
Build this out in your own environment using the following specifications. This exercise takes approximately 45–60 minutes.
Scenario: You're building an internal IT Help Desk app. There are two roles: Help Desk Agents (who handle tickets) and Help Desk Managers (who assign tickets and view reports).
Setup:
Create two security groups in Azure AD (or use existing ones): helpdesk-agents and helpdesk-managers. Add yourself to one of them for testing.
Create a new Canvas App with these screens: scrAgentView, scrManagerView, scrAccessDenied, scrLoading.
Set scrLoading as the start screen in App settings.
Step 1 — Wire up the Entra connector and add it to your app.
Step 2 — Write the App.OnStart formula:
// Set loading state
Set(varAppLoaded, false);
// Resolve roles
Set(
varIsAgent,
MicrosoftEntra.IsMemberOf("YOUR-AGENTS-GROUP-ID", User().Email).value
);
Set(
varIsHDManager,
MicrosoftEntra.IsMemberOf("YOUR-MANAGERS-GROUP-ID", User().Email).value
);
Set(
varUserRole,
If(varIsHDManager, "Manager", varIsAgent, "Agent", "NoRole")
);
// Navigate to appropriate screen
Switch(
varUserRole,
"Manager", Navigate(scrManagerView, ScreenTransition.Fade),
"Agent", Navigate(scrAgentView, ScreenTransition.Fade),
Navigate(scrAccessDenied, ScreenTransition.Fade)
);
Set(varAppLoaded, true)
Step 3 — On scrAgentView.OnVisible:
If(varUserRole = "NoRole", Navigate(scrAccessDenied))
Step 4 — On scrManagerView.OnVisible:
If(Not(varIsHDManager), Navigate(scrAccessDenied))
Step 5 — Add a label on scrAgentView that reads:
"Welcome, " & User().FullName & ". Your role: " & varUserRole
Step 6 — Add a button on scrAgentView labeled "Manager Reports" with:
Visible: varIsHDManagerOnSelect: Navigate(scrManagerView)Step 7 — Test by:
scrAccessDenied appearsMistake 1: Forgetting .value on IsMemberOf
The IsMemberOf function returns a record like {value: true}, not a raw boolean. If you write:
Set(varIsManager, MicrosoftEntra.IsMemberOf("...", User().Email))
Then varIsManager holds a record. When you use it in If(varIsManager, ...), Power Fx treats a non-blank record as truthy — so everyone appears to be a manager. Always append .value.
Mistake 2: Using User().Email vs User().Email format mismatches
In some tenants, IsMemberOf requires the user's UPN (user principal name) which may differ from their email. If your AD queries return unexpected false results for users who should be in a group, try using User().Email explicitly and verify it matches the UPN format in Azure AD (often firstname.lastname@company.com).
Mistake 3: OnStart not re-running when expected
App.OnStart only runs when the app is first opened. It does NOT re-run if a user navigates back to the app from another browser tab without closing it. Role changes in AD don't take effect in a running app session. This is expected behavior — communicate to users that they need to close and reopen the app after a role change.
Mistake 4: Controls flickering before OnVisible redirect fires
If you put sensitive data directly on a restricted screen and rely on OnVisible to redirect unauthorized users, there's a brief moment where the screen renders before the redirect. Use containers with Visible = varIsITAdmin on any sensitive content so that even if a user somehow reaches the screen, the sensitive controls simply don't render.
Mistake 5: Hardcoding Group IDs in multiple places
You'll use group IDs in OnStart and nowhere else — because the variables handle everything downstream. But if you're also calling IsMemberOf inside screen-level logic for some reason, don't paste the raw GUID multiple times. Store the GUIDs in named variables during OnStart:
Set(varGroupID_Managers, "a1b2c3d4-1111-2222-3333-aabbccdd0002");
Then reference varGroupID_Managers in any subsequent calls. When group IDs change (they shouldn't, but organizations restructure), you update one line.
Mistake 6: The app works in Studio but not for other users
This is usually a connector permission issue. The Microsoft Entra connector requires each user to consent to it when they first use the app. If your organization has policies restricting connector consent, users will see an error. Your admin needs to pre-authorize the connection at the environment level, or enable admin consent for the connector in the Power Platform admin center.
Debugging Tool: Use the Power Apps Monitor
In Power Apps Studio, go to Advanced Tools > Monitor. Run your app from Monitor to see all connector calls in real time, including IsMemberOf calls, their payloads, and responses. This is the fastest way to verify that calls are being made with the right Group IDs and user emails, and to see what the API is actually returning.
You've now built a production-ready role-based access control system for a Canvas App using Azure AD group membership. Let's recap the key design principles:
Resolve roles once, at startup. Your App.OnStart makes AD calls and stores results in boolean variables and a role-string variable. Everything else reads variables, not AD.
Defense in depth for screen access. Hide unauthorized navigation buttons so users don't know restricted screens exist. Add OnVisible guards on restricted screens as a second line of defense against navigation bugs.
Dynamic UI from role variables. Every button, container, gallery, and label that should vary by role reads the role variables. Combine role checks with data state for context-aware UI — not just role-aware.
Handle the "NoRole" case explicitly. Every user who opens your app who isn't in any expected group should land on a helpful scrAccessDenied screen, not a broken experience.
Consider performance for 5+ roles. Switch from multiple IsMemberOf calls to a single GetMemberGroups call with local filtering when your role model grows.
Where to go from here:
Row-level security in your data source: Role-based UI prevents unauthorized actions, but consider whether you also need row-level data filtering. A warehouse staff member shouldn't be able to query the Dataverse API for all purchase orders — Power Automate flows with service accounts and Dataverse table permissions can enforce this at the data layer.
Audit logging: When privileged actions happen (approvals, status overrides), log them to a Dataverse table with User().Email, the timestamp, and the action taken. This is non-negotiable in regulated industries.
Combining with Dataverse security roles: Azure AD group membership can drive Canvas App UI. Dataverse security roles can enforce data access at the API level. Using both together gives you genuine end-to-end RBAC, not just cosmetic UI changes.
Named formulas for role checks: Power Fx named formulas (in newer Canvas App versions) let you define reusable expressions like IsPrivilegedUser = varIsManager || varIsITAdmin that calculate on demand. As your role logic grows, named formulas keep it readable.
The pattern you've built here is used in real enterprise apps serving hundreds of users. It's the right architecture for the job — maintainable, performant, and grounded in your organization's existing identity infrastructure.
Learning Path: Canvas Apps 101