My Take: Plans Fix the Parts of Conditional Access That Break at Scale
Policy plans are the Conditional Access change I am most excited about this year. This section is opinion. The rest of the article is documentation and test results.
Anyone who has run Conditional Access across a large Zero Trust style deployment knows where it hurts. One break glass exclusion missed on one policy out of dozens is a lockout waiting for a bad day. Finding the right policy in a flat list means reading names and trusting that whoever wrote them followed the convention. Both problems have one cause. The policy is the only unit Conditional Access gives you, so every shared decision gets copied into every policy.
A plan moves that shared decision up a level. Exclusions and common targeting live in one place, every rule inherits them, and no rule can remove an exclusion the plan set. The interface shows you the objective first and the scenarios under it. That does not remove the need for a practical, well documented naming convention, and I get to that in section 04. It does clean up the interface, and it gives the Zero Trust policy engine room to grow past what a flat list can hold.
Growth is why this matters now. Microsoft's concept page says Global Secure Access deployments often need hundreds of network-scoped rules for the same users and apps, and it names agent activity as a second use case. My view is that GSA and agents will put a load on Conditional Access that it has never carried. Policy plans are the answer to that exponential growth. Today the documented quota is 240 policies per tenant, in any state. In my testing, plan rules did not count against it. The numbers are in section 05.
Microsoft documents 240 Conditional Access policies per tenant, in any state. Ali Tajran tested it and found a maximum of 244, in a post published 7 May 2026. His post adds a note that Microsoft raised the cap from 195 to 244 in April 2026. I have not found that change on Microsoft Learn, which still says 240, so treat 240 as the documented figure and 244 as an observed one. Read his post: Conditional Access policy maximum limit.
Policy plans are in preview. Behavior and limits described here could change before general availability.
A Plan Is the Objective, the Shared Conditions Are the Common Ground, and Rules Are the Scenarios
This is how I describe a plan. The plan is the objective, such as requiring compliant devices or securing agent activity. The shared conditions are what every scenario under that objective has in common. The rules are the scenarios. Learn defines the same three parts, and every plan needs at least one rule.
Plan details
Name, description, tags and owners. Owners record accountability and grant no permissions. Details do not affect enforcement.
Shared conditions
Optional include and exclude targeting that applies to every rule in the plan.
Rules
One enforcement scenario each, with its own assignments, conditions, grant controls and session controls.
Four inheritance rules from Learn decide how you design one:
- A rule inherits the plan's targeting and cannot narrow it.
- Exclusions accumulate. A rule can add its own, and it can never remove one set at plan level.
- Identity type must be consistent in shared conditions. A plan that targets users cannot also target agents at plan level.
- All users and All resources exist at rule level only.
At sign-in, each rule is evaluated as an independent decision, the same way as a standalone policy, and each rule appears separately in the sign-in logs. Plans sit beside your existing policies. Every applicable policy and every applicable rule must be satisfied, with no precedence between them. A plan rule and a classic policy can both enforce on the same sign-in.
Tags are the feature I find most interesting
Each plan can carry free-text tags. They show as a column in the list, you set them when you edit the plan, and Graph exposes them as a tags property. Learn describes them as a way to categorize and organize plans. Learn notes that classic policies have no built-in owner attribute, so its guidance is to encode ownership in the policy name and keep a registry outside Entra. Plans put owners on the object itself, with tags beside them. I tagged every test plan in section 05 so they are easy to find and remove afterward.
Three Portal Behaviors Learn Does Not Cover Yet
Guest conditions go in the plan or in the rule, not both
While building a GuestAccess plan I tried to save guest and external user types in the plan's shared identities while the plan's rules also targeted external users. The portal refused with this message:
The condition property 'Users.IncludeGuestsOrExternalUsers' cannot be set on both shared conditions and rule conditions. Set it on one or the other, but not both.
The concept page describes inheritance in general terms and does not list this property, so you meet it when you save. Decide per plan where guest targeting lives before you build the rules.
Edit all rules changes every state in one step
The plan page has an Edit all rules action. It sets On, Report-only or Off for every rule in the plan at once and asks you to confirm that the update will have significant impact. That is a fast way to promote a whole plan out of Report-only, and just as fast a way to switch protection off. The dialog warns that turning all rules off or to Report-only leaves the organization unprotected. I have not tested whether protected actions cover plan edits, so check that before you rely on it as a control.
I'm adding Policy Plans support to CA Policy Analyzer
I started building this in as soon as plans hit preview. There's now a Policy Plans (Preview) tab that lists each plan with its rules, their states and what they inherit from the plan. It's a read-only early look, and I have not wired plan data into the Findings, Templates or CIS tabs yet. Until I do, treat a clean Findings tab as incomplete if your protection now lives in plans.
Plans Give You Fewer Things to Name, So Each Name Has to Carry More
Please think about a scalable naming convention before you build your first plan. Plans do not make naming optional. They move it up a level. The plan name is what an admin scans in the list, the rule names are what they read once inside, and the section 05 numbers show you will not have unlimited plans to waste on one-off scenarios.
Here is the structure I am starting with:
[Prefix] - Plan - [Domain] - [Tier]
| Segment | What it holds | Examples |
|---|---|---|
| Prefix | Who owns or deploys the plan | IAC |
| Plan | A fixed literal, so plans are never confused with classic policies in exports or logs | Plan |
| Domain | The product area the rules protect | Entra, Intune, Purview, GSA, DFCA |
| Tier | The objective or layer inside that domain | Foundation, Baseline, Compliance, InsiderRisk, DLP, Network, SessionControls |
Rule names inside a plan keep your existing policy schema, such as IAC - GLOBAL - GRANT - MFA - B2B-Guest. The plan carries the objective and the rule carries the scenario, so neither name repeats the other.
I would rather scope a plan to a product area and an objective than create one plan per scenario. If a plan fills up, a GSA network plan being the likeliest, the convention extends without breaking. Split on the Tier segment, for example GSA - Network-EMEA and GSA - Network-AMER, and do not invent a new pattern.
This is the convention I am testing while plans are in preview. It is not a standard. If yours works better, the point still stands: decide before the first plan exists.
Testing the Limits: 100 Rules per Plan, and 99 Plans per Tenant in Practice
The only Conditional Access limit Microsoft documents is 240 policies per tenant, counting every state. I could not find a published maximum for rules per plan or plans per tenant, and the Graph reference for creating rules does not give one. If your design is one rule per network or per branch office, you need the real figure before you build, so I tested both.
| Limit | What I measured | Error returned |
|---|---|---|
| Rules per plan | 100. The 101st was refused. | 2009 |
| Plans per tenant | 99 created. The 100th was refused, and the message says the limit is 100. | 2165 |
| Plan rules against the 240-policy quota | Separate. 64 classic policies plus 206 plan rules is 270 objects, and nothing complained. | None |
Setup
I ran everything against my own tenant on 4 November 2026, through Lokka (an MCP server for Microsoft Graph), signed in as a Conditional Access Administrator. Plans do not exist in v1.0 yet, so every call went to the beta endpoint. Before touching anything I recorded the starting state:
| Object | Count |
|---|---|
| Classic Conditional Access policies | 64 |
| Existing policy plans | 4 |
| Rules across those plans | 12 |
No test object could touch a real sign-in. The sandbox plan, ZZ - LimitProbe - Sandbox, excludes the breakglass group in its shared conditions. Every rule is disabled, targets one demo user and uses block as the grant control.
The calls
A plan cannot exist without a rule, so the plan and its first rule go up in a single POST. The shared conditions carry the exclusion and the client app types. The rule carries the demo user, all applications and the block grant.
Create the plan with Probe-001POST https://graph.microsoft.com/beta/identity/conditionalAccess/plans
Content-Type: application/json
{
"displayName": "ZZ - LimitProbe - Sandbox",
"description": "Temporary sandbox to probe rule limits. All rules disabled. Safe to delete.",
"tags": ["LimitProbe"],
"sharedConditions": {
"clientAppTypes": ["all"],
"users": { "excludeGroups": ["<break-glass-group-id>"] }
},
"rules": [
{
"displayName": "Probe-001",
"state": "disabled",
"conditions": {
"users": { "includeUsers": ["<demo-user-id>"] },
"applications": { "includeApplications": ["All"] }
},
"controls": {
"grantControls": { "operator": "OR", "builtInControls": ["block"] }
}
}
]
}
Every later rule goes to the rules endpoint with the same body and an incrementing name:
POST https://graph.microsoft.com/beta/identity/conditionalAccess/plans/{planId}/rules
One call per rule is slow, so I used Graph's $batch endpoint with 20 POSTs per batch. Two details make it work. Each request carries dependsOn pointing at the one before it, which forces Graph to run them in order (section 05 gotchas explains why that matters). Each also sends Prefer: return=minimal, so Graph answers with a 204 and a Location header instead of echoing the whole rule.
Two requests from a batch{
"requests": [
{
"id": "1",
"method": "POST",
"url": "/identity/conditionalAccess/plans/{planId}/rules",
"headers": { "Content-Type": "application/json", "Prefer": "return=minimal" },
"body": { "displayName": "Probe-083", "state": "disabled", "...": "..." }
},
{
"id": "2",
"dependsOn": ["1"],
"method": "POST",
"url": "/identity/conditionalAccess/plans/{planId}/rules",
"headers": { "Content-Type": "application/json", "Prefer": "return=minimal" },
"body": { "displayName": "Probe-084", "state": "disabled", "...": "..." }
}
]
}
One batch timed out on the client side partway through. Before retrying I listed the plan's rules and confirmed the count was still 82, so the final number is not inflated by a duplicate batch. Recount before you retry anything that timed out.
Rule 101 is refused with error 2009
Probe-001 through Probe-100 all created cleanly. Probe-101 failed:
{
"status": 400,
"body": {
"error": {
"code": "BadRequest",
"message": "The server could not process the request because it is malformed or incorrect.",
"innerError": {
"message": "2009: The maximum number of rules per plan has been exceeded. Please remove some rules and try again."
}
}
}
}
Read innerError.message. The top-level code and message are generic, and a "malformed" request reads like a schema problem when the body for rule 101 was identical to the 100 that worked. A script that logs only the top-level error will tell you your JSON is wrong. The request chained after Probe-101 never ran, and Graph returned a 424 FailedDependency for it. The plan now sits at exactly 100 rules.
Plan 100 is refused with error 2165, and the tenant held 99
For the tenant-wide number I created plans named ZZ - PlanProbe - NNN. Each holds one disabled rule for the demo user and excludes the breakglass group at plan level. The tenant started with 5 plans: my 4 real ones and the rule sandbox. Probes 001 to 094 created cleanly. Probe 095, which would have been the 100th plan, failed:
"innerError": {
"message": "2165: The number of policy plans has reached the limit of 100."
}
A retry a few seconds later returned the same error. The message says 100, but my tenant held 99. I listed every plan to check: 5 of mine plus 94 probes, no duplicates, no paging, and GET /identity/conditionalAccess/deletedItems/plans came back empty. Either the check is off by one or something Graph does not show counts as a plan. Design for 99.
Plan rules do not count against the 240-policy quota
By the end the tenant held 64 classic policies and 206 plan rules, 270 objects in all. That is past the documented 240 and past the 244 that Ali Tajran observed for classic policies, and nothing complained about the policy count. The two quotas are separate. On paper that allows 240 classic policies plus 99 plans of up to 100 rules each. I did not test whether a tenant-wide rule total exists below that.
Gotchas for anyone scripting against plans
Parallel writes to one plan fail. My first batch sent two rule creations to the same plan with no dependsOn. One succeeded and the other returned a 503:
"code": "ServiceUnavailable", "message": "Failed to store the policy plan or rule descriptions. Please retry the request."
This is a write collision, not a limit and not throttling. The plan appears to be stored as one object, so concurrent writes fight over it. After I serialized the requests, 98 more creations ran without a single 503.
The rules endpoint accepts no query options. I wanted to count rules without pulling every body back, and each option I tried was refused:
| Query option | Response |
|---|---|
| $count | 404, no controller named 'plans' matches the URI |
| $select=displayName | 400, 'displayName' cannot be used in $select |
| $select=id | 400, 'id' cannot be used in $select |
| $top=5&$skip=100 | 400, the limit of '0' for Top query has been exceeded |
| $expand=rules on /plans | 400, Query option 'Expand' is not allowed |
The only way to count rules is a plain GET that returns every rule in full, roughly 2 KB each. At 100 rules that is a large response, and it matters for anyone building inventory, drift detection or backup on top of plans. This differs from the list-rules reference, which lists $count, $filter, $orderby, $select, $skip and $top as supported. In my tenant, the beta endpoint did not honor them.
Plan creation is throttled. Twenty plans in one batch went through. The next batch, sent seconds later, returned a 429 with Retry-After: 1. A retry with 10 plans returned another 429 with Retry-After: 10, and waiting about 75 seconds cleared it. After that, batches of 10 plans with a 30 second gap ran to the limit. I never saw a 429 creating rules, though those batches were spaced further apart.
Admin365 reports that existing Conditional Access backup tools and cmdlets do not see plans yet (Entra Conditional Access Policy Plans Explained). I have not tested this myself. If you move policies into plans, confirm that whatever you rely on for backup and drift detection covers them.
The maximums in short, and why I care
A plan holds 100 rules. A tenant held 99 plans, though the error message says 100. Plan rules sit outside the 240 policy quota for classic policies. None of these are numbers to design toward. I pushed to the edge to find where it is, and a real plan will stop being readable long before it gets there.
Two habits matter more than the numbers. Test in a lab tenant before you trust a preview feature, and keep every test object tagged and disabled so it cannot reach a sign-in. Then name things well from the start. Plans reduce how much there is to name, so each name carries more, and a convention decided before the first plan exists costs far less than one retrofitted later.
I think this matters because of where organizations are heading. Teams getting granular with Zero Trust design principles end up with extensive policy sets. Every new persona, device state, network or agent scenario adds policies, and each one repeats the same exclusions and targeting. Plans give that growth a structure. Shared decisions are made once and scenarios become rules, with an owner on the object. That is how I expect large policy sets to stay understandable as the granularity increases, and it is why I will keep testing them.
Two Questions I Could Not Settle From Graph
The first is why the plan cap bites at 99 when the error message says 100. The second is whether a tenant-wide total of plan rules exists below 99 plans of 100 rules each. Filling every plan to the brim is a lot of writes for a lab, and I stopped short of it.
Learn lists policy plans as preview, so both numbers could change before general availability. I will rerun this test when plans go GA and update the figures here.
Everything in this article is testing of a product in preview. Behavior, limits, error codes and portal wording are subject to change, and my figures reflect one tenant on 4 November 2026. Verify against current Microsoft documentation before you design around them.