Wednesday, September 16, 2026

Excluding Admins from a Dynamic "All Users" Group in Microsoft Entra ID (The Hard Way, So You Don't Have To)

Standard



 If you manage identity in any reasonably mature Entra ID tenant, you've probably wanted a dynamic group that captures "everyone" for baseline Conditional Access policies, licensing, onboarding automation while cleanly leaving out your admin accounts. It sounds like a two-minute rule. It isn't. Here's what actually works, and why the obvious approaches don't.

The problem

A dynamic "all users" group is simple enough on its own:

(user.objectId -ne null) and (user.userType -eq "Member") and (user.accountEnabled -eq true)

The trouble starts the moment you try to add "and exclude anyone assigned an admin role." Directory role assignments and PIM eligibility are not attributes the dynamic membership rule engine can evaluate. There's no user.assignedRoles property, and role membership isn't exposed to the rule builder at all.

Dead end #1: memberOf

Entra does have a preview rule operator that lets a dynamic group pull in members of another group:

user.memberof -any (group.objectId -in ['<admin-group-id>'])

This works but only in isolation. memberOf cannot be combined with any other condition in the same rule. You cannot write:

(user.accountEnabled -eq true) and not(user.memberof -any (group.objectId -in ['<id>']))

Entra's validator rejects it outright, often with an unhelpful error like Invalid object type '...' specified on property 'memberof'. It's also worth knowing this operator is in preview and being sunset Microsoft has flagged it for retirement, so building anything long-term on top of it is a bad bet regardless of the combination limitation.

There's a second wrinkle if your admin structure is a "group of groups": memberOf only reads direct members of whatever group ID you reference. Users sitting inside a nested group underneath it won't be picked up.

Dead end #2: Custom security attributes

The next instinct is Entra ID Governance's custom security attributes feature cloud-native, flexible, seems perfect. It isn't usable here either: custom security attributes are explicitly not supported as a property in dynamic group membership rules. They exist for access reviews and RBAC conditions, not for this.

What actually works: directory extension attributes

The feature that is supported in dynamic rules, and is cloud-writable regardless of on-prem sync status, is a Microsoft Entra directory extension attribute (also called a custom extension property). It's registered against an app registration, and once created it behaves like any other attribute:

(user.objectId -ne null) and (user.userType -eq "Member") and (user.accountEnabled -eq true) and (user.extension_<appid>_isPrivilegedUser -ne "Admin")

This combines freely with other conditions because it's a normal property, not a relationship like memberOf.

Registering it

There's no portal UI for this step, it's Graph or PowerShell only:

Run on this powershell - 

Connect-MgGraph -Scopes "Application.ReadWrite.All"
$app = New-MgApplication -DisplayName "Directory Extensions Owner App"
New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{
    Name = "isPrivilegedUser"
    DataType = "String"
    TargetObjects = @("User")
}

One gotcha that cost real debugging time: an app registration alone isn't enough. Writing the extension property onto a user fails with The following extension properties are not available until the app also has a service principal in the tenant:

Run on this powershell - 
New-MgServicePrincipal -AppId $app.AppId

Once that exists, Update-MgUser -AdditionalProperties @{ "extension_<appid>_isPrivilegedUser" = "Admin" } works as expected.

Bridging the gap: a tagging script

Since the dynamic rule can't read group or role membership directly, something has to translate "is this person an admin" into "does this attribute say Admin," and keep it current. That's a scheduled script's job:

  1. Recursively walk the admin group(s). If your admin structure nests groups inside groups, a flat membership read misses anyone underneath the nested layer so this needs to recurse until it hits actual users.
  2. Account for PIM. If your admin groups are PIM-for-Groups enabled, Get-MgGroupMember only returns active members. Anyone with an eligible-but-not-activated assignment won't show up in that call at all, which quietly defeats the purpose of the exclusion. Eligible members need a separate query Get-MgIdentityGovernancePrivilegedAccessGroupEligibilitySchedule unioned in with the active set.
  3. Tag, then untag. Every run tags newly-found admins and clears the tag from anyone no longer under any admin group, so people who lose admin status fall back into the "all users" group automatically on the next cycle rather than staying excluded indefinitely.

Running it unattended

This lands naturally in an Azure Automation runbook:

  • Create the Automation Account
In the Azure portal, search "Automation Accounts" > Create. Pick your subscription/resource group, a name (e.g. entra-admin-exclusion-automation), and a region. Leave other defaults as-is for a simple runbook like this one.
  • Enable a system-assigned managed identity
Inside the Automation Account, go to Account Settings > Identity. Turn System assigned to On and save. This gives the Automation Account its own identity in Entra ID that Connect-MgGraph -Identity will authenticate as  no stored credentials or secrets needed.

  • Import the required Graph modules into the Automation Account
Go to Modules > Browse gallery (or Modules > Add a module for newer UI). Add these, one at a time, and wait for each to finish installing before adding the next: Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Identity.Governance, Microsoft.Graph.Applications. These match what your script imports/uses locally.



  • Create the runbook and paste in the script
Go to Runbooks > Create a runbook. Choose PowerShell as the type and match the runtime version to what you tested locally (PowerShell 7.2 is the current recommended default). Paste in the full script content, with one change: swap the interactive Connect-MgGraph -Scopes "..." line for Connect-MgGraph -Identity, since Automation authenticates via the managed identity, not a browser prompt.

  • Test the runbook before scheduling it
Use the Test pane (Edit view > Test pane) to run it once manually and confirm the same output you saw locally: groups walked, PIM-eligible counts, users tagged, and no permission or extension-property errors. This catches any permission-scope gaps from step 3 before it's running unattended.

  • Create and link a schedule
Go to Schedules > Add a schedule > create new, set your recurrence (hourly or daily is typical for this kind of exclusion-tag refresh), then go back to the runbook's Schedules tab and link the schedule to it. This is what makes it run unattended going forward.

  • Check job history periodically
Under Jobs, review recent runs' output and any warnings/errors (the Write-Warning lines in the script surface here). Since PIM eligibility and group membership can shift, keep an eye on this for the first few runs to confirm the numbers look stable before trusting it fully.


Create Dynamic Group

Create a new Security group and select Dynamic query


Dynamic Query -  

(user.objectId -ne null) and (user.userType -eq "Member") and (user.accountEnabled -eq true) and (user.extension_<App ID>_isPrivilegedUser -ne "Admin")

Note - you need to include this on the App ID we created on the above registration process without any Space or hyphen. 

Now you can see that New custom extension appear for dynamic rules and able select it. 

Custom Attributes in Entra ID: Choosing and Implementing the Right Approach

Standard


 

In on-premises Active Directory, we rely on Custom Extension attributes 1 to 15. Once those are used up, extending further means a schema extension. Several of these slots are already mapped into Entra ID and used to park attributes or tags, mainly to drive Entra identity lifecycle events and dynamic group membership rules.

For new requirements like this, Microsoft offers two ways to store custom values in Entra ID:

  1. Entra ID Custom Security Attributes
  2. Entra Directory Extension Attributes

Option 1: Custom Security Attributes

These live under Entra ID Governance, in Protection > Custom security attributes.

Limitation: they are explicitly not supported in dynamic group membership rules. Microsoft's own documentation confirms this: these attributes are not exposed to the dynamic group evaluation engine, so there is no way to reference them in a rule, regardless of syntax.

This rules them out for our use case.

Option 2: Directory Extension Attributes

Also called custom extension properties, these are registered against an app registration in the tenant.

Key characteristics:

  • Fully supported in dynamic group rules, using the syntax user.extension_<appId>_<AttributeName> -eq "value"
  • Cloud-native and writable via Graph regardless of on-prem sync status. Entra Connect has no ownership of them, so there is no sync-conflict risk (unlike extensionAttribute1 to 15)
  • No Hybrid Runbook Worker required. A standard cloud Automation Account with a managed identity is sufficient

This is the right fit for our scenario.

How Directory Extension Attributes Work

Ownership through an app registration

Every extension attribute must be registered under an app registration, and that app becomes its owner. This is why a dedicated app is created for the purpose. It is not optional scaffolding; it is how the feature is designed to work. One app can own multiple extension attributes, and different apps can each own their own sets.

Naming convention

Once registered, the attribute receives a permanent, auto-generated name:

extension_<appId-without-dashes>_<attributeName>

Example: an attribute named isPrivilegedUser, registered under app c1c518d8-ee7d-48af-8d89-eb4da4c8f20e, becomes:

user.extension_c1c518d8ee7d48af8d89eb4da4c8f20e_isPrivilegedUser

This is the only name that exists for the attribute. It is used consistently across Graph API calls, dynamic group rules, and PowerShell. There is no separate friendly name exposed anywhere.

Two flavors of directory extension attributes

Cloud-native attributes (what we are building) Created directly via Graph or PowerShell against an app registration we control. Fully cloud-writable, with no dependency on sync.

Entra Connect synced attributes If the on-prem AD schema is extended and those custom attributes are synced up, Entra Connect auto-registers them as directory extensions as well, but under its own app called the Tenant Schema Extension App, not one we create. In this case, on-prem AD remains the source of truth, and the cloud side is read-only for those values. This is the same ownership constraint we already have with extensionAttribute1 to 15, just under a different app.

The Tenant Schema Extension App

When Microsoft Entra Connect is installed with the classic sync engine (not Cloud Sync) and custom on-prem AD attributes are selected for sync, the installer automatically registers a special application called the Tenant Schema Extension App. It is created once, during installation. We never create it ourselves, and it does not appear in the standard app list unless the filter is switched to "All Applications."

During the Entra Connect configuration wizard's custom settings step, it presents a list of AD schema attributes eligible for extension. Whichever ones are selected get registered as directory extension attributes under this single app, using the same naming pattern described above:

extension_<TenantSchemaExtensionAppId>_<attributeName>

The key point: every synced attribute shares the same app ID, the Tenant Schema Extension App's own ID. This is different from our setup, where a separate app is created specifically for one custom attribute. On-prem synced attributes all funnel through this one shared app.

Classic sync vs Cloud Sync

Classic Entra ConnectEntra Connect Cloud Sync
Owning appTenant Schema Extension AppApp with identifier URI api://<tenantId>/CloudSyncCustomExtensionsApp
App creationAuto-created at install timeNot guaranteed to exist; created on demand

For Cloud Sync, Microsoft's documentation shows a check-and-create pattern:

$cloudSyncCustomExtApp = Get-MgApplication -Filter "identifierUris/any(uri:uri eq 'api://$tenantId/CloudSyncCustomExtensionsApp')"

If this returns empty, the app must be created. If the app exists but has no service principal, that also needs to be created:

POST https://graph.microsoft.com/v1.0/servicePrincipals
{ "appId": "<application appId>" }

Our Custom App vs the Tenant Schema Extension App

Our custom appTenant Schema Extension App
Created byUs, manually, via Graph/PowerShellEntra Connect, automatically, at install time
Source of truthCloud (writable via Graph anytime)On-prem AD (Entra Connect owns it, syncs one-way)
Used forThe isPrivilegedUser tagging we builtAny AD attribute exposed via Entra Connect's wizard

Setup: Creating a Cloud-Native Attribute

There is no portal UI for this step. It is Graph or PowerShell only.

Connect-MgGraph -Scopes "Application.ReadWrite.All"

$app = New-MgApplication -DisplayName "Directory Extensions Owner App"

New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{
    Name = "isPrivilegedUser"
    DataType = "String"
    TargetObjects = @("User")
}

New-MgServicePrincipal -AppId $app.AppId

How to add another Extension for same app.

To add another attribute, reuse the same app — don't run New-MgApplication again, or you'll end up with a second, duplicate "Directory Extensions Owner App" and a confusing mess of two apps owning different attributes.

Step 1 — Get the existing app's ID (don't recreate it):

powershell

$app = Get-MgApplication -Filter "displayName eq 'Directory Extensions Owner App'" $app.Id

This should return 7ffcea56-09ff-4d28-982d-94de68690e36 — the same app you already have a service principal for.

Step 2 — Register the new extension property on that same app:

powershell

New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{ Name = "yourNewAttributeName" DataType = "String" TargetObjects = @("User") }

Just swap yourNewAttributeName for whatever you want to call it.

That's it — no new service principal needed. Since this attribute is registered under the same app that already has a working service principal, it inherits that and should be immediately writable via Update-MgUser -AdditionalProperties, without repeating the New-MgServicePrincipal step.

Step 3 — Confirm it's there:

powershell

Get-MgApplicationExtensionProperty -ApplicationId $app.Id

You should now see both isPrivilegedUser and your new attribute listed side by side, both usable in dynamic group rules and via Graph once you copy the exact name field for each.




Q&A

  • Why create an app registration for this at all?

Entra ID's user profile works like a fixed form with predefined fields: name, email, job title, and so on. A new field cannot simply be invented; Entra needs something to own that field before it will let one be created. An app registration is a container that can claim ownership of custom attributes. It is not being used as an app in any functional sense: no sign-in, no permissions consumed by it. It exists purely so the attribute has a home, which is why it is named descriptively, such as "Directory Extensions Owner App."

  • How does the attribute get added to a user?

Once registered on the app, the attribute becomes a real, optional field on every user object in the tenant, effectively a new extension attribute slot that did not exist before. It stays empty until something writes a value into it. For each user identified as an admin, the script opens that user's record and writes a value into the field. It is a normal write operation, just to a field we created rather than a built-in one.

  • Can this attribute be seen in the Entra ID portal?

Not on a user's profile page; there is no tab listing custom extension attributes and their values for a user. Values can only be viewed via Graph or PowerShell, for example:

Get-MgUser -Property "extension_..."

There is one place it does surface in the UI: when building a dynamic group rule in the Entra admin center, the rule editor has a "Get custom extension properties" button. Pointing it at the owning app and refreshing makes the attribute selectable in the rule builder dropdown. Outside of that workflow, it is not visible anywhere in the portal.


  • Does this conflict with the on-prem extensionAttribute1 to 15?

No. Despite the similar naming, these are separate, unrelated storage locations.

extensionAttribute1 to 15 are legacy AD schema attributes. For synced users, Entra Connect owns them and pushes values one-way from on-prem AD to the cloud, which is exactly why they could not be used here: writing to them from the cloud side risks being overwritten by the next sync cycle.

Our new attribute, extension_<appid>_isPrivilegedUser, lives in a completely separate namespace tied to our app's ID. It is cloud-native, cloud-writable, and Entra Connect has no awareness of it or authority over it.