I was going through recent Message Center notifications (via Merill’s MC archive) looking for my next post and saw that memberOf is being retired. This post and its companion module are the result.
Microsoft Entra ID has a dynamic membership operator called memberOf that has only ever been a public preview. It lets a dynamic group fill itself with the members of other groups, which is the one thing the rule language couldn’t otherwise express. On November 3, 2026, the preview ends, and every object still using it freezes. Message Center item MC1448379 carries the notice, and the Learn page for the operator now opens with it.
Microsoft’s own migration guidance tells you to export your dynamic groups and look for rules that contain memberOf. A text search is the fastest way to do that, and it’s wrong in both directions, so I built a read-only PowerShell module called EntraMemberOf that parses each rule the way the rule engine would and reports what it finds. The module scans pasted rule text, an exported CSV or JSON file, or a whole tenant through Microsoft Graph. The last of those covers all three places the operator can live, because groups are only one of them.
The action-by date is a week out as I write this. If you’ve already cleared your tenant, this post is a way to check your work. If you haven’t, it’s a way to find out how much there is.
What stops on November 3
Learn states the effect in one sentence, and I’m going to quote it, because the module quotes it too: “After November 3, 2026, dynamic membership groups, dynamic administrative units, and entitlement management auto-assignment policies that use the memberOf operator stop updating and remain in their last known state.”
Read that for what it leaves out. Nothing is deleted. Nothing errors. The group keeps existing with whoever was in it on the day, and from then on, nobody joins or leaves it through the rule. If that group feeds a license assignment, a Conditional Access policy, a Teams team or a SharePoint site, those keep working for the frozen membership and stop tracking the source groups. Learn lists those consequences itself: “stale access and enforcement gaps, including outdated Teams and SharePoint access, Conditional Access targeting, group-based licensing, and access package assignments.” It says no more than that about what happens to an existing member’s access, so neither does this post.
Learn names the affected object types:
- Dynamic membership groups, user or device, with a
memberOfrule. - Dynamic administrative units, whose membership rule uses the same language.
- Entitlement management auto-assignment policies, where the rule lives inside the policy. The auto-assignment policy page uses a different word for these: starting November 3, a policy whose rule uses
memberOfis “quarantined.” The policy remains, assignment processing stops, and no assignment is added or removed untilmemberOfcomes out of the rule.
Microsoft’s stated reason is scale. From the migration section: “During preview, we observed that using memberOf can slow dynamic membership processing for all groups in a tenant.” Not just the memberOf groups. All of them. The page says an alternative is being developed, with no date.
One thing the Learn page settles: a paused group isn’t exempt. The migration guidance says a group that’s no longer needed can be paused or deleted, and that’s all it says about paused objects. The module reports a paused group as a finding with its state shown, because there’s no sentence anywhere saying a paused memberOf group survives the date.
The shape of a memberOf rule
The documented form, from the Learn page’s own examples, is a user rule and a device rule:
Several source groups go in one list:
| |
Notice the casing. The page’s property tables write it memberOf and the examples write it memberof. Property names in membership rules are documented as case sensitive, and both spellings come from Microsoft, so the module matches the name without regard to case and doesn’t read anything into which one it finds.
The rule builder in the admin center doesn’t support memberOf. The Learn page says so in its get-started steps: select Edit and write the rule in the Rule syntax box. Every memberOf rule in your tenant was typed into that box, posted to Graph, or created from PowerShell. There’s no form that produced one, so there’s also no screen telling you how many you have. Open one of my test groups and the blade shows both halves of that: the builder row sits empty with “Some items could not be displayed in the rule builder,” and the rule itself is down in the Rule syntax box, under Microsoft’s own banner saying not to use memberOf in production.

The preview limitations are the other half of the shape, because the scanner checks against them:
- 500
memberOfdynamic groups per tenant, counting toward the 15,000 dynamic group quota. - 50 source groups per dynamic group.
- A
memberOfdynamic group can’t be the source for anothermemberOfdynamic group. memberOfcan’t be combined with other rules or operators. A rule that says “members of group B who are also in Redmond” is listed as one that will fail.- Only direct members of a source group come across, and membership doesn’t update when a source group is deleted or loses members. The users stay until the rule changes.
- Public cloud only, and the tenant needs Entra ID P1 or P2.
I liked memberOf. It covered things I’d otherwise have had to script, and I hope Microsoft’s replacement performs better and does the job as well.
Reading the rule the way the engine does
The quickest check is Select-String -Pattern memberof over an export. Microsoft’s own finder script for auto-assignment policies does the equivalent, $rule -match '(?i)memberof'. For most tenants that’s probably fine. I didn’t want “probably.”
A text search reports things that aren’t there. user.jobTitle -eq "memberOf coordinator" contains the word and doesn’t use the operator. So does a rule that matches on a group’s display name, if someone named a group after the feature. And it can’t tell you what it found: whether the rule is a user or a device rule, which source groups it names, whether it’s combined with an -and that Learn says is unsupported, or whether the rule was readable at all. A rule with an unclosed quote looks the same to a regex as a rule without one.
The module tokenizes the rule first. A membership rule is a small expression language: identifiers such as user.department or group.objectId, operators written -eq or eq, string values in double or single quotes, arrays in brackets, parentheses. ConvertTo-MembershipRuleToken walks the text once and returns one token per piece with its kind, text and position. Quoted strings come back as one token each, with the escapes Learn documents already undone: a backtick before a double quote, and a doubled single quote. The double-quoted branch:
| |
A string that never closes comes back as Unterminated and isn’t guessed at. Operators are returned by name, lowercased, without the dash, whether they were written -in, in, or with the en dash some Learn examples carry. Anything the tokenizer doesn’t recognize is an Unknown token.
With tokens in hand, the match is one line. A rule is Retired when a Word token, outside any string, is one of the two documented property names:
$catalog.RetiredProperties is user.memberof and device.memberof, loaded from Data/MemberOfRules.psd1. That data file is the only place in the module that names a date, a limit, a Review reason or a remediation sentence, so when Microsoft changes the page, there’s one file to edit. An opt-in Pester test fetches the raw Markdown of both Learn pages from the MicrosoftDocs/entra-docs repo and fails if the retirement date, the rule forms, the limits or the page’s ms.date no longer match it. It passed on October 27, with the page’s ms.date still at 08/04/2026.
From the hit, the scanner walks forward through the memberOf expression and records the quantifier that follows the property, the operator that follows group.objectId, and every string value in the list, which are the source group ids. Learn documents -any and -in; a rule using something else is still Retired, with a note saying what it used. An -and, -or or -not outside the expression means the rule combines memberOf with something else, and the note says that too.
Everything that mentions memberOf some other way lands in a second level called Review, and the list is short on purpose:
| Code | What it means |
|---|---|
StringLiteral | memberOf appears only inside a quoted value |
UnparsedRule | The tokenizer hit something it couldn’t read, such as an unclosed quote |
UnqualifiedMemberOf | An identifier contains memberOf but isn’t user.memberOf or device.memberOf |
MemberOfAsOperator | Written -memberOf, which no page defines |
A Review result is never promoted to Retired. It’s a reason to read the rule with your own eyes, and the note on each one tells you what was seen and where. UnparsedRule is the reason the level exists: a rule the scanner couldn’t finish reading isn’t a rule it can vouch for, so it comes back as Review and not as Clean. A text search would have called that same rule clean, or matched it, and either way wouldn’t have told you the quote was missing.
It isn’t a parser for the whole rule language, and it doesn’t evaluate a rule. It answers one question per rule and says when it can’t. Rule text mode on one rule of each level:
| |
Three reads over Graph
Tenant mode needs Microsoft.Graph.Authentication and an existing Connect-MgGraph session. The rest of the Graph SDK isn’t used, and the manifest declares no RequiredModules, so rule text and file mode run on a machine without it installed. Every request is a GET, and the one function that sends requests has no -Method parameter and accepts only a Graph URL, the same arrangement as the branding scanner in Entra Is Retiring 58 Sign-in CSS Properties, and the First List Had 19.
All three reads are v1.0. These are the calls the finished module makes, and what each one returned on the tenant I tested against:
| |
Groups. The filter is groupTypes/any(s:s eq 'DynamicMembership'), which is an advanced query, so the ConsistencyLevel: eventual header and $count=true go on every page, and the page size is 999. The $select asks for id,displayName,groupTypes,membershipRule,membershipRuleProcessingState. On the list call, with that filter, the rule and the processing state both came back, so there’s no per-group GET. One thing I wanted to confirm was whether this filter works at all; it did, and I didn’t need a fallback.
Administrative units. GET /directory/administrativeUnits with $select=id,displayName,membershipType,membershipRule,membershipRuleProcessingState. The resource documents $filter only on id, displayName and description, so there’s no server-side way to ask for dynamic units; the module reads them all and keeps the ones whose membershipType is dynamic. The three properties came back on the list call, so again no per-unit GET.
Auto-assignment policies. GET /identityGovernance/entitlementManagement/assignmentPolicies?$expand=accessPackage&$top=50, v1.0. A policy is an auto-assignment policy when automaticRequestSettings is present, and its rules are the attributeRuleMembers entries in specificAllowedTargets, each with a membershipRule. That’s step for step the script on Microsoft’s auto-assignment policy page, with the regex swapped for the parser, and the $expand is what gives the report the access package name.
On the policy read I have to be careful about what I verified. My development tenant’s E5 developer license doesn’t include Microsoft Entra ID Governance, and a POST to create an auto-assignment policy came back NoGovernanceSku, for a plain user.department rule and a memberOf rule alike. I created a manual policy on a test access package instead, and the scan listed it, saw no automaticRequestSettings, and skipped it. That proves the list call and the expand, and only that. A Retired policy result is verified only against the scripted responses in the unit tests, whose shape matches Microsoft’s own finder script. If you have a Governance license and a policy with a memberOf rule, I’d like to hear whether the report matches.
Source group names. The ids inside a memberOf rule are just ids. A report that says “depends on All Sales and All Marketing” is one an admin can act on, so after the scan the module collects every source group id from every Retired result and looks them up fifteen at a time with $filter=id in (...). An id Graph doesn’t return maps to null, and the result’s note says the group wasn’t found. Learn says a memberOf group’s membership doesn’t update when a child group is deleted, so a dangling id is a real finding.
Permissions. Microsoft documents GroupMember.Read.All or Group.Read.All for the group calls, AdministrativeUnit.Read.All for the units, and EntitlementManagement.Read.All for the policies. The module asks for those three:
My run was app-only with a certificate, on an app that held Group.ReadWrite.All and Directory.ReadWrite.All and none of the three read permissions by name. All three list calls succeeded on that token. I haven’t tested a least-privilege app, and the README says so in the same words. An app-only session is scoped to the process, so connect and scan in the same PowerShell window.
A kind that can’t be read, most often because a permission is missing, is written to the error stream, the other kinds still scan, and the summary records it as incomplete coverage. I’ll come back to that distinction, because the summary is built around it.
Running it against the export and the tenant
The first set of output below comes from a fixture file in the repository. The second comes from a real development tenant, with objects I created for the test, and I’ve labeled which is which.
From the export fixture
The admin center’s dynamic group export is a CSV, so the module reads one. The export is on the All groups blade, where Download groups sits on the toolbar beside New group.

Columns are matched by name: membershipRule or rule, id or objectId, displayName or name, an optional membershipRuleProcessingState, and an optional kind column for files that mix groups, units and policies. A file with no rule column is an error, because a file of groups with no rules in it shouldn’t come back clean.
The fixture is eight rows built to cover every level:
| |
| |
Walk down the rows. Sales and Marketing names both of its source groups, because they’re in the same file. Field Devices shows a bare id, because its source isn’t in the file; in tenant mode that’s where the name lookup fills it in. Rollup of Rollups is paused, still reported, and flagged yes under Chain, because its source group is Sales and Marketing, itself a memberOf group. Says memberOf and Broken rule are the two Review rows, the string literal and the unclosed quote. Assigned group has no rule and comes back Clean with a note saying it had nothing to read.
The tables are only the default view. Each result also carries Rule, SourceGroupIds, Quantifier, Operator, Note, Remediation and the rest; Format-List or Select-Object * shows all of it.
From a real tenant
My development tenant had one dynamic group and no memberOf rules at all, so to see a tenant scan find something I created four objects outside the module: a plain security group, a memberOf dynamic group whose source is that group, a second memberOf group whose sources are the first memberOf group and an id that doesn’t exist, and a dynamic administrative unit whose rule is memberOf over the plain group. The module can’t create anything, so that was a separate script with a write permission the module doesn’t hold.
I’ve left those objects in my test tenant so I can follow up after November 3 and see what state they’re in.
| |
The nested group’s note carries both data quality signals: it depends on another memberOf group, and one of its source ids points at nothing. Both are things an admin working the list would want to know before deciding whether to rewrite the rule or delete the group.
The surprises in that run came from Graph.
Graph still accepts new memberOf rules a week before the retirement. The POST /groups with a memberOf membership rule succeeded on October 28, and so did the nested one, whose source is another memberOf group, which the Learn page says can’t be done. So did the POST /directory/administrativeUnits with a dynamic membership type and a memberOf rule. Nothing at the API stops an admin from creating, today, an object that freezes on November 3.
The other is lag, and it affects what a scan can see. The dynamic group list is an eventually consistent query, and the first scan after creation listed one of the two new groups. The administrative unit list is a plain GET with no filter, and it returned an empty collection for a full minute after the unit was created, by id or by list, with or without $select. The same lag made the name lookup return 404 for a group that was seconds old; the module reported that as a warning, kept the ids, and the next run resolved the names. If you change an object and scan right away, wait a minute and scan again before trusting the result.
Clean against Incomplete
A tenant with no memberOf rules and a tenant where the policy read failed on a missing permission both produce zero Retired findings. Those are different answers, and the summary’s Outcome keeps them apart:
Clean is only reached when every requested kind was read to the end and nothing uses the operator. Incomplete means nothing was found but at least one kind couldn’t be read, and IncompleteKinds names which. The message for that case says in words that it’s not an all-clear. If you’re running this across a list of client tenants, the first is a tenant you can stop thinking about and the second is a tenant where somebody needs to fix a permission and run it again. A scanner that printed “no memberOf usage found” over a 403 would be worse than no scanner, because you’d believe it.
Fixing what it finds
The module doesn’t remediate anything, and I don’t think it should. Replacing a memberOf rule changes who is in a group, and that’s a decision to make with the rule open and the source groups in front of you. What it does is attach Microsoft’s guidance to each Retired result as a Remediation string, kept to what the Learn migration section says for that object kind.
For a dynamic group, Learn says to replace memberOf with supported rule operators, or convert the group to assigned membership. Then validate the membership, and if the group is no longer needed, pause or delete it. The “supported rule operators” link goes to the general rule reference, and as far as I can see there’s no direct equivalent in it. A memberOf rule says “the members of group X.” The supported operators say things about the user’s own attributes, so the rewrite depends on what the members of the source groups have in common, and whether anything on the user object records it.
A dynamic administrative unit gets the same choice, replace the rule with supported logic or convert the unit to assigned membership, with one extra validation step: check both the membership and the administrative scope afterward, because a unit scopes role assignments and a membership change can change what an admin can touch.
Auto-assignment policies are where Learn says to replace memberOf with supported attribute-based operators where possible, and if no equivalent rule exists, to plan an alternative assignment method before retirement. The policy page adds the ordering: plan the alternative before you remove the policy that contains the rule, so that assignments aren’t dropped. And validate the access package assignments after the change.
The dependency chains are the ones to do first. A memberOf group whose source is another memberOf group breaks twice on November 3, once for its own rule and once because its source has frozen. The Chain column and the DependsOn property find those, and they’re the rules where rewriting the outer one is pointless until the inner one is settled.
Where the scan stops
The scanner reads the rule, not the tenant’s behavior. A rule the engine would reject for some other reason comes back as whatever the scanner finds in it, and a rule that’s valid but pointless is still Clean.
In file mode, source group names come only from the file. An export of ten groups that references a source group outside those ten shows the id. The admin center export’s column names are the ones the module knows; an export with different headings needs them renamed first.
I haven’t tested a least-privilege app, delegated sign-in, or an auto-assignment policy with a memberOf rule against a real tenant, for the license reason above. PowerShell 7.4 or later is required and 7.6 is what I tested on. Windows PowerShell 5.1 isn’t claimed, and Linux and macOS are untested, though nothing in the module is Windows-specific.
And the eventual consistency is real. A scan run seconds after a change can miss the change, for any of the three kinds.
Getting the module
EntraMemberOf is in my TechbyJeff repo at Powershell/Entra/EntraMemberOf, with the README carrying the tenant observations in more detail. It’s version 0.1.0 and isn’t on the PowerShell Gallery like some of my others, so import it from the folder:
| |
The unit suite needs Pester 6.2 or later and runs with Invoke-Pester -Path .\Tests. On my machine that’s 93 passing tests, with the two integration suites skipping themselves until you connect to a tenant or set ENTRAMEMBEROF_ONLINE=1. None of the unit tests reach Graph.
You have until November 3. The export takes a minute, the scan takes less, and the rules it finds are the ones that were always going to need rewriting by hand.
References
- Configure dynamic membership groups with the memberOf operator (preview), Microsoft Learn, the source of the date, the limits and the migration guidance
- Configure an automatic assignment policy for an access package, Microsoft Learn, including Microsoft’s script for finding policies that use
memberOf - MC1448379, Message Center archive
- List groups, List administrativeUnits and List assignmentPolicies, Microsoft Graph
