Calculating the Licensing Requirement for Entra Conditional Access Policies

Figuring Out Who Needs Entra P1 Licenses

In the discussion about the licensing gap highlighted in the Entra admin center between the use of conditional access policies and accounts with Entra P1 or P2 licenses, I made the point that the number of licenses owned by the tenant might be more than the actual usage as reported by Entra license insights and confirmed by analysis of Entra sign-in audit records. It might be possible to bring everything back into balance by reassigning licenses to the accounts which actually use conditional access.

The PowerShell code included in the article shows how to find the user accounts that have used conditional access policies during sign-ins over the last 30 days. However, that’s not quite the same as satisfying Microsoft licensing requirements.

Microsoft’s Licensing Requirements

The requirements for conditional access policies are a little fuzzy. The documentation simply says, “Using this feature requires Microsoft Entra ID P1 licenses.” Lawyers can debate what “using” means. A clue comes from the product usage insight, which defines conditional access usage as: “the number of unique users with at least one Conditional Access policy evaluated during the measurement period.” I can find no other Microsoft statement with a more precise definition for the licensing requirement. The recent licensing gap notifications also do not explain how the licensing scope is calculated.

In any case, it’s wise to take a practical approach to these matters. If a tenant has deployed conditional access policies to protect connections, it is likely that most if not all user (member) accounts need to be licensed. Administrators will exclude some accounts from conditional policy processing, like break glass accounts, but the norm for a user account doing regular work is that they will be subject to conditional access policies for purposes such as enforcement of multifactor authentication and securing access to high-priority apps.

In that context, the target set for licensing is defined as user accounts that are not disabled and have at least one assigned license. This filter excludes disabled accounts such as those used for shared mailboxes, guest accounts covered through MAU-based licensing, and dormant directory objects that have no access to Microsoft services. It should find the set of accounts that connect to Microsoft services, which are therefore likely to be processed by conditional access policies.

With the target set defined, we can think about checking the licensing situation for the active conditional access policies within a tenant. This can be done by checking the included and excluded conditions defined for policies. Some policies will apply to all users with perhaps exclusions for break glass accounts. Some policies will be more specific about the users and groups that come within their scope.

Processing Conditional Access Policies

The Get-MgIdentityConditionalAccessPolicy cmdlet can find the set of active policies (those currently enabled):

[array]$Policies = Get-MgIdentityConditionalAccessPolicy -All -Filter "state eq 'enabled'"

See this article for more information about using the Microsoft Graph PowerShell SDK to manage conditional access policies.

After finding the policies, it’s a matter of analyzing the users, groups, and roles covered by each policy. These values are held in the Conditions.Users property for a policy. The aim is to determine the set of accounts covered by each policy and combine the sets together into an array of unique account identifiers. The set of included and excluded accounts for a policy can be found with:

[array]$PolicyIncludedUsers = $Policy.Conditions.Users.IncludeUsers
[array]$PolicyExcludedUsers = $Policy.Conditions.Users.ExcludeUsers

Some complications exist, such as when policy scope is set by membership of groups (remembering to use transitive membership) or the membership of directory roles. In practice, directory roles like Global administrator are often used to target privileged accounts, so role membership must be considered when calculating the effective scope of Conditional Access policies. These requirements are met by using the Get-MgGroupTransitiveMemberAsUser and Get-MgDirectoryRoleMember cmdlets to fetch the identifiers of members and include them in the mix.

License Validation

After processing all the policies, we can find any product subscriptions that include the Entra P1 service plan:

# Fetch licensed users with a license that includes an Entra P1 or P2 service plan
$EntraPlan1 = "41781fb2-bc02-4b7c-bd55-b576c07bb09d"
$EntraPlan2 = "eec0eb4f-6444-4f95-aba0-50c24d67f998"
[array]$EntraSkus = Get-MgSubscribedSku -All | Where-Object {
    $_.ServicePlans.ServicePlanId -contains $EntraPlan1 -or
    $_.ServicePlans.ServicePlanId -contains $EntraPlan2 }  | Select-Object SkuId, SkuPartNumber, PrepaidUnits

And then find the set of user accounts holding a license with an Entra P1 or P2 service plan (P2 confers the rights contained in a P1 license):

[array]$LicensedP1Users = Get-MgUser -ConsistencyLevel Eventual -CountVariable Licenses -PageSize 500 -Property Id, displayName, userPrincipalName, assignedPlans -Filter "(assignedPlans/any(s:s/servicePlanId eq $EntraPlan2) or assignedPlans/any(s:s/servicePlanId eq $EntraPlan1)) and userType eq 'Member' and accountEnabled ne false"

We can’t use this information immediately because the presence of a service plan in the license information maintained for an account might be a disabled plan due to the removal of a license. We need to check that the service plan is enabled.

This code checks each user to validate if the Entra P1 service plan is enabled and builds a list of user accounts with Entra P1 enabled:

$ListCALicensedUsers = [System.Collections.Generic.List[object]]::new()
ForEach ($P1User in $LicensedP1Users) {
   $CheckPlan = $P1User.assignedPlans | Where-Object {$_.ServicePlanId -eq $EntraPlan1} | Select-Object -ExpandProperty CapabilityStatus
   If ($CheckPlan -eq "Enabled") {
        $ReportLine = [PSCustomObject]@{
            UserId          = $P1User.Id
            DisplayName     = $P1User.DisplayName
            UserPrincipalName = $P1User.UserPrincipalName
        }
        $ListCALicensedUsers.Add($ReportLine)
   }    
}

The next step is to calculate the set of users who don’t have a P1 or P2 license but need one because they are found within a policy scope:

# Get the set of users who aren't licensed for Entra P1 or P2 but are within the scope for at least one CA policy
[array]$UsersNeedingEntraP1 = $TotalUsersFound | Where-Object { $_ -notin $ListCALicensedUsers.UserId }

All processing is done using account identifiers (GUIDs). This is good because the identifiers are immutable, but a step is needed to translate the GUIDs into user-friendly information like display names. An early step at the start of the script fetched the set of licensed users. To avoid the need to retrieve user data again, we create an array based on the set of identifiers for accounts needing Entra P1 licenses:

[array]$UsersWithoutEntraP1Licenses = $Users | Where-Object { $_.Id -in $UsersNeedeingEntraP1 } | Select-Object DisplayName, UserPrincipalName, Id

The script finishes by generating an Excel workbook (if the ImportExcel module is available – Figure 1) or CSV file containing details of the users who need Entra P1 licenses. Alternatively, accounts can be excluded from CA policies to reduce the licensing requirement.

Listing accounts that should have Entra P1 licenses for conditional access policies.
Figure 1: Listing accounts that should have Entra P1 licenses for conditional access policies

Running the Code

You can download the full script from the Microsoft 365 for IT Pros GitHub repository. The code uses the Microsoft Graph PowerShell SDK and should be run in an interactive session when signed into an administrator account with access to policies, groups, and user accounts. Alternatively, use app-only authentication with an app assigned the necessary permissions.

I do not claim that the script proves tenant compliance with Microsoft licensing requirements. Coding against imprecise information is always difficult, and Microsoft does not publish a precise definition of Conditional Access licence consumption. What it does do is identify the enabled member accounts that fall within the effective scope of active Conditional Access policies and compares that set against accounts with Entra P1 or P2 licenses. It seems like a reasonable place to start when investigating a reported licensing gap.

I’m sure that some will find ways to improve the code, such as adding a section to handle the calculation of an Entra P2 licensing requirement. In any case, have fun with the PowerShell!


Need help to write and manage PowerShell scripts for Microsoft 365, including Azure Automation runbooks? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Microsoft 365 for IT Pros eBook bundle.

11 Replies to “Calculating the Licensing Requirement for Entra Conditional Access Policies”

  1. Hi Tony,

    thank you for your insights!
    Currently I have the question (which Microsoft doesn’t want to answer us), if we also have to license Agent 365 for all users, if all users can profit from (at least) one agent, which is also secured by a conditional access policy, which is recommended by several MS partners of ours as a security measure??
    This would have a huge impact on our budget – but from what I could find, there’s no way around this requirement (if agents are protected via conditional access)?

    Thanks,
    Thomas

    1. According to https://learn.microsoft.com/en-us/entra/identity/conditional-access/agent-id:

      “Conditional Access for agents requires Microsoft Entra ID P1 or P2 and a Microsoft Agent 365 license for each user. Enforcement of Agent 365 licensing is coming soon. Network controls for agents require Microsoft Entra Internet Access. For more information, see What is Microsoft Entra Agent ID.”

      I read this to mean that when users benefit from agent processing, those agents must be licensed. The language isn’t very precise, so I followed the normal guideline of when someone receives benefit from a Microsoft feature, they must be licensed to use that feature.

      However, in Microsoft’s June 2026 licensing changes. Microsoft introduced dedicated service plans:

      ENTRA_CONDITIONAL_ACCESS_FOR_AGENTS
      ENTRA_ID_PROTECTION_FOR_AGENTS

      and added them to Microsoft Agent 365 and Microsoft 365 E7. Sources reporting the Microsoft announcement state that customers using Conditional Access for agents must have the appropriate Agent 365/E7 licensing. If your users have E7, they have P2, and don’t need any additionl licensing for CA for agents.

      Putting everything together, I think we can say:

      Conditional Access for agents requires Entra ID P1 or P2 plus Microsoft Agent 365 licensing. Microsoft states that a Microsoft Agent 365 license is required “for each user,” but Microsoft has not yet published detailed guidance explaining exactly how the licensing scope is calculated

      1. Hi Tony,

        thank’s a lot for your swift answer – that gives me confidence for the next budget round…!

  2. If/when Microsoft decides to actually enforce licensing on Conditional Access, they better have a much better reporting than now. A lot of guessing and pulling data from various sources is required to make sense now. But i did use your script from the last article to pull sign-ins data for one of our customers, then compared that with full users export from M365 and roughly I ended up with a number that aligned with a graph in Entra (say 100 P1 licenses and reporting 110 usage). There were a few users with just Exchange Online licenses or even no licenses at all, but still somehow doing sign-ins and generating Conditional access success events.

  3. Thanks for the script, Tony.

    FYI:
    Ran it against a > 10k-user tenant and only got ~150 accounts back in the output, which was below expectation.

    the main Get-MgUser call (and the P1/P2 license lookup) sets -PageSize 500 but is missing -All. Without -All, the SDK returns exactly one page and silently drops everything past it: no error, no warning. On our tenant that meant the report was built from a random 500-user slice instead of the full population.

    Adding -All to both Get-MgUser calls fixed the main problem for us.

    1. This is what happens when you walk away from a coding session and leave an unsynchronized change in Visual Studio Code… Fixed now. Thanks for highlighting the problem.

      1. Great post, thanks for sharing this Tony!

        Small heads-up: there’s a broken line-continuation backtick in the Get-MgUser call for the $LicensedP1Users block (around line 160), which throws:

        Get-MgUser: A parameter cannot be found that matches parameter name ‘All`’.

        PowerShell only treats a backtick as a line continuation when it’s the very last character on the line, with no trailing space. Since it sits right after -All with no space, PowerShell reads it as part of the parameter name (`-All“) instead of a continuation, which triggers that error.

        Switching to splatting fixes it at the root and reads a bit nicer too:

        ——
        $LicensedP1UsersParams = @{
        ConsistencyLevel = “Eventual”
        CountVariable = “Licenses”
        PageSize = 500
        All = $true
        Property = “Id”, “displayName”, “userPrincipalName”, “assignedPlans”
        Filter = “(assignedPlans/any(s:s/servicePlanId eq `$EntraPlan2) or assignedPlans/any(s:s/servicePlanId eq `$EntraPlan1)) and userType eq ‘Member’ and accountEnabled ne false”
        }
        [array]$LicensedP1Users = Get-MgUser @LicensedP1UsersParams
        ——–

        While I was in there I also spotted two smaller things in the Else branch that handles policies scoped to specific groups/users/roles:

        $IncludedUserIds = $PolicyIncludedUsers | Where-Object { $_ -notin $IncludedGroupUserIds } references $IncludedGroupUserIds, which isn’t defined anywhere in the script. It won’t throw (PowerShell just treats it as $null), but the filter silently does nothing. Looks like it should be $AllGroupMembers instead, to avoid double-counting users included both directly and via a group.

        $Groups is initialized to $null each loop iteration but never populated, so the Groups column in the final report always ends up empty. Resolving Policy.Conditions.Users.IncludeGroups through Get-MgGroup to grab the DisplayName would fill that in.

        Neither throws an error so they’re easy to miss — figured I’d mention them since I was already digging around fixing the backtick. Thanks again for putting this together, really useful script overall! 🙂

      2. Sugar! I thought that I had fixed the tick but clearly I hadn’t. I’ve now done so, and I also fixed the function to return group members to return group names as well. And I found another couple of irritating bugs too. This is what I get from transferring code into functions at the last moment!

  4. No problem, Tony—I just went over it, and it looks good!

    Just wanted to point out that there’s a minor side effect resulting from the change to $Groups you made: it appears that the reset $Groups = $null at the beginning of the ForEach loop ($Policy in $Policies) was omitted in the update.

    Since $Groups is only reassigned when a policy has `IncludeGroups`, if a policy with groups is followed by another one targeting “All Users” or that only includes roles or individual users (without groups), that subsequent policy will inherit the group names from the previous policy in the report, since $Groups is never cleared in between.

    This won’t generate any errors, so it’s easy to overlook during testing, but the “Groups” column might end up displaying outdated data for some policies. It should be a one-line fix: simply re-add `$Groups = $null` at the beginning of the loop, alongside the existing resets for `$PolicyIncludedUsers` and `$PolicyExcludedUsers`.

    As a little extra tip for the future: this kind of “variable leakage between loop iterations” is exactly the kind of thing PSScriptAnalyzer can help detect automatically if you ever want to run it on the script before committing it to the repository. It could save you some back-and-forth in cases like this.

    Thanks again for this script, it saves my day —I really appreciate it!

    P.S.: I think I’m going to buy your book 😀

    1. Actually, what’s even better (and is documented in the book) is that before you release a script, create a new PowerShell session and run it to see what breaks due to unitialized variables, etc. And of course, I forgot to do it this time round. I added the $Groups initialization back but hadn’t pushed the change to GitHub (it is there now).

      But hey, I’m not a professional developer (I was with VAX COBOL and VAX BASIC, but that was a long time ago), and my PowerShell is strictly in the hack to make it work category. Jeffrey Snover and I worked together at Digital Equipment Corporation, and I have often told him quite how many hours of my life PowerShell has occupied… One of these days, I shall become acceptably proficient!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.