Every Entra tenant I inherit has the same Conditional Access (CA) sprawl: thirty policies named like “MFA test 2 FINAL”, overlapping scopes nobody can reason about, and a hand-curated app list that silently stopped covering new workloads. CA is the load-bearing wall of a Zero Trust posture, and ad-hoc CA rots fast because every change is additive and nobody ever deletes. This article lays out the framework I deploy instead: a persona-based design with a strict numbering scheme, authentication strengths and authentication context for step-up, and filters that replace brittle app and device lists. It assumes Conditional Access Administrator (or Security Administrator) plus Entra ID P1; authentication context and strengths add no license beyond P1.
1. Why ad-hoc CA rots, and what “good” looks like
CA policies are evaluated as a logical AND across all matching policies: a sign-in must satisfy every policy that targets it. That makes the system unforgiving of overlap. Two policies that both target “All users” but disagree on grant controls do not merge cleanly in anyone’s head; the only way to know the effective result is to simulate it.
The fix is not “fewer policies.” It is a deterministic partition of the identity estate so that for any (user, app, device, location) tuple you can name exactly which policies apply and why. Three rules get you there:
- Every identity belongs to exactly one persona, expressed as group membership.
- Every policy carries a CAxxx number whose first digit encodes the persona, so the policy set is self-documenting.
- Every persona has a global block as its backstop, so the default answer to an unmodelled sign-in is “deny,” not “allow.”
That last point is the difference between a framework and a pile of allow-rules. The value is in applying the structure consistently, not in any single policy.
2. Define the personas and the numbering scheme
Start by partitioning every account into a persona. The five that cover almost every tenant:
| Persona | Who | Backstop posture |
|---|---|---|
| Admins | Privileged-role holders, PIM-eligible | Phishing-resistant MFA + compliant/SAW device, always |
| Internals | Standard employees | MFA + compliant or hybrid-joined device |
| Guests | B2B collaboration users | MFA (trust home-tenant MFA where agreed), no device control |
| Workloads | Service principals, managed identities | Location + risk via workload-identity CA |
| Service accounts | Non-human directory accounts that still sign in interactively | MFA-exempt on named devices only, otherwise blocked |
Assign a numeric band per persona and a slot per policy function within it. I use:
| Band | Persona | Example function slots |
|---|---|---|
| CA001-CA099 | Global / all personas | CA001 block legacy auth, CA002 block unsupported device platforms |
| CA100-CA199 | Admins | CA101 require phishing-resistant MFA, CA102 require compliant device, CA199 global block |
| CA200-CA299 | Internals | CA201 require MFA, CA202 require compliant/hybrid device, CA299 global block |
| CA400-CA499 | Guests | CA401 guest MFA strength, CA499 global block |
| CA500-CA599 | Workloads | CA501 workload-identity location lockdown |
| CA600-CA699 | Service accounts | CA601 service-account device filter, CA699 global block |
The xx9 slot in each band is reserved for that persona’s global block all apps except an explicit allow-list — the deny-by-default backstop. Anything you forget to model lands there. Name policies CAxxx-<persona>-<function>, e.g. CA101-Admins-Require-PhishResistant-MFA: the number sorts them; the suffix tells a reviewer what it does without opening it.
Personas map to groups. Use assigned groups for break-glass-sensitive personas (Admins) so a dynamic-rule misconfiguration cannot silently empty the scope; dynamic groups are fine for Internals.
3. Build the policy matrix: grant, session, and the global block
Think in three layers per persona: grant controls (what you must prove to get in), session controls (constraints once in), and the global block (the floor). Here is the Internals require-MFA policy authored against Microsoft Graph v1.0. Note the break-glass exclusion is non-negotiable on every blocking or MFA policy.
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess","Policy.Read.All"
$params = @{
displayName = "CA201-Internals-Require-MFA"
state = "enabledForReportingButNotEnforced" # report-only first
conditions = @{
users = @{
includeGroups = @("<internals-group-id>")
excludeGroups = @("<breakglass-group-id>")
}
applications = @{ includeApplications = @("All") }
clientAppTypes = @("all")
}
grantControls = @{
operator = "OR"
builtInControls = @("mfa")
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
Session controls live under sessionControls. The two I reach for most: sign-in frequency for high-value apps, and persistent-browser disable for unmanaged devices.
sessionControls = @{
signInFrequency = @{
isEnabled = $true
type = "hours"
value = 4
frequencyInterval = "timeBased"
}
persistentBrowser = @{
isEnabled = $true
mode = "never"
}
}
The global block (CA299) is the backstop. It blocks all apps for the persona, then you carve exceptions by excluding an explicit allow-group of apps you have consciously approved. Because CA is AND-evaluated, this block coexists with the MFA policy: a sign-in must pass MFA and not be blocked.
$block = @{
displayName = "CA299-Internals-GlobalBlock-Unapproved-Apps"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeGroups = @("<internals-group-id>"); excludeGroups = @("<breakglass-group-id>") }
applications = @{ includeApplications = @("All"); excludeApplications = @("<approved-app-ids>") }
clientAppTypes = @("all")
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $block
4. Authentication strengths and authentication context for step-up
Authentication strength replaces the blunt mfa grant control with a named, version-managed combination of methods. There are three built-ins, referenced by stable GUID:
| Built-in strength | GUID |
|---|---|
| Multifactor authentication | 00000000-0000-0000-0000-000000000002 |
| Passwordless MFA | 00000000-0000-0000-0000-000000000003 |
| Phishing-resistant MFA | 00000000-0000-0000-0000-000000000004 |
For the Admins persona, require phishing-resistant MFA. Critically, you cannot combine mfa in builtInControls with an authenticationStrength in the same policy — the MFA built-in is the strength ...002, so it is redundant and rejected.
$adminCa = @{
displayName = "CA101-Admins-Require-PhishResistant-MFA"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeGroups = @("<admins-group-id>"); excludeGroups = @("<breakglass-group-id>") }
applications = @{ includeApplications = @("All") }
clientAppTypes = @("all")
}
grantControls = @{
operator = "OR"
authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" }
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $adminCa
List built-ins (and any custom strengths) from the beta endpoint:
az rest --method GET \
--uri "https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies?\$filter=policyType eq 'builtIn'"
Authentication context is the other half of step-up. Instead of forcing phishing-resistant MFA on every admin sign-in, you bind it to sensitive actions — a privileged portal blade, a labeled SharePoint site, a sensitive operation in your own app. An authentication context is just an ID c1-c25 with a friendly name; the ID is emitted in the acrs claim of the access token, and downstream resources demand it to trigger step-up. Create one against v1.0:
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/authenticationContextClassReferences" \
--headers "Content-Type=application/json" \
--body '{
"id": "c5",
"displayName": "Privileged admin actions",
"description": "Step-up to phishing-resistant MFA for sensitive operations",
"isAvailable": true
}'
Then write a CA policy whose target is the context, not an app list. The key is includeAuthenticationContextClassReferences:
$ctxCa = @{
displayName = "CA103-Admins-StepUp-On-c5"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeGroups = @("<admins-group-id>"); excludeGroups = @("<breakglass-group-id>") }
applications = @{ includeAuthenticationContextClassReferences = @("c5") }
}
grantControls = @{
operator = "OR"
authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" }
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $ctxCa
Now c5 can be wired to PIM role activation, Purview sensitivity labels, or your own apps via the claims challenge — one context, reused everywhere, instead of a step-up policy per app.
Why this matters: authentication context decouples “the action is sensitive” from “which app it lives in.” A new privileged app inherits step-up the moment it requests
c5. No policy edit.
5. Filters for devices and apps to replace brittle lists
Hand-maintained app lists are the most common rot vector. Two filter mechanisms kill them.
Filter for apps lets you target apps by a custom security attribute instead of enumerating object IDs. Tag apps once (for example CASecurity/highImpact = true) and write the policy against the tag — new apps inherit the policy by being tagged, with no policy change.
Filter for devices is the bigger win. The rule lives at conditions.devices.deviceFilter with a mode (include/exclude) and a rule string using dynamic-membership syntax. Operators are -eq -ne -in -notIn -contains -startsWith -notStartsWith -endsWith -notEndsWith -and -or. Supported properties include trustType (AzureAD = Entra joined, ServerAD = hybrid joined, Workplace = registered), deviceOwnership, isCompliant, enrollmentProfileName, and extensionAttribute1-15.
For the Service accounts persona, exempt MFA only on named secure devices and block everywhere else. Tag the approved devices with extensionAttribute2 = SvcAcctKiosk, then exclude that filter from the block:
{
"displayName": "CA601-SvcAccounts-Block-Except-NamedDevices",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"users": {
"includeGroups": ["<svc-accounts-group-id>"],
"excludeGroups": ["<breakglass-group-id>"]
},
"applications": { "includeApplications": ["All"] },
"clientAppTypes": ["all"],
"devices": {
"deviceFilter": {
"mode": "exclude",
"rule": "device.extensionAttribute2 -eq \"SvcAcctKiosk\""
}
}
},
"grantControls": { "operator": "OR", "builtInControls": ["block"] }
}
A critical gotcha lives in the evaluation table: for unregistered devices, all properties are null, so a positive operator (-eq) never matches them and the filter does not apply. Express device exclusions with a property the device must positively have and let the global block catch the null/unregistered case — or use a negative operator when you specifically intend to catch unregistered devices. Note too that extensionAttribute1-15 populate only for Intune-managed, compliant, or hybrid-joined devices, and the rule string caps at 3072 characters — exactly why you tag rather than enumerate device IDs.
6. Manage CA as code with Graph and CI/CD
Click-ops does not scale across personas. Treat policies as JSON in Git, deploy through a pipeline, and keep a restorable backup. Export the current estate:
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
--query "value" > ca-policies-backup.json
A minimal backup/restore loop in a pipeline. Restore creates from the stored definitions (strip read-only fields like id, createdDateTime, modifiedDateTime first):
# Backup
$pol = Get-MgIdentityConditionalAccessPolicy -All
$pol | ConvertTo-Json -Depth 12 | Out-File "ca-backup-$(Get-Date -f yyyyMMdd).json"
# Restore one policy from a sanitized definition
$def = Get-Content "./policies/CA101.json" -Raw | ConvertFrom-Json
New-MgIdentityConditionalAccessPolicy -BodyParameter $def
For a real GitOps flow, tools like Microsoft365DSC or the Maester test framework assert the deployed state matches the repo on every run, so drift (someone toggling a policy in the portal at 2 a.m.) fails the pipeline. The non-negotiable rule: new and changed policies deploy in enabledForReportingButNotEnforced first, and a human promotes to enabled only after the report-only telemetry is clean.
7. Closing the common gaps
Four gaps that defeat most CA designs:
- Legacy authentication. Basic-auth protocols (older Exchange, IMAP/POP, SMTP AUTH) bypass MFA entirely. Ship
CA001-Global-Block-Legacy-AuthtargetingclientAppTypes = ["exchangeActiveSync","other"]with a block. This is the single highest-value policy in the tenant. - Device code flow and authentication transfer. Phishing campaigns increasingly abuse device-code and cross-device flows. Block them for personas that never need them via the authentication flows condition (
conditions.authenticationFlows.transferMethods = "deviceCodeFlow,authenticationTransfer"), excluding the handful of legitimate kiosk/IoT scenarios by device filter. - Emergency-access accounts. Two cloud-only break-glass accounts with long passphrases stored offline, excluded from every policy, and wired to a sign-in alert. If you lock yourself out with a bad phishing-resistant rollout, these are the only way back in. Excluding them is why every snippet above carries
excludeGroups. - The unmodelled persona. Anyone not in a persona group hits no persona policy — and therefore no global block. Add a tenant-wide
CA002that blocks all apps for “All users” excluding the union of every persona group plus break-glass. It is the framework’s outermost fence.
Enterprise scenario
A platform team running a regulated EU fintech had ~40 CA policies and a recurring audit finding: contractors were reaching the Azure management plane from unmanaged laptops. Their existing “require compliant device” policy targeted an app list that someone had to update by hand, and Windows Azure Service Management API had never been added. The constraint: they could not simply require compliant devices for all admin access, because their break-glass and a small set of vendor-support sessions legitimately came from secure admin workstations (SAWs) that were not Intune-enrolled but were tagged.
We collapsed it into the persona model. Admins got CA102 requiring a compliant device for the ARM/Azure Management resource, scoped by filter for apps (tag highImpact=true) so no app could ever be forgotten again. The SAW exception became a device filter rather than an app or user carve-out:
{
"applications": {
"includeApplications": ["797f4846-ba00-4fd7-ba43-dac1f8f63013"]
},
"devices": {
"deviceFilter": {
"mode": "exclude",
"rule": "device.extensionAttribute1 -eq \"SAW\""
}
}
}
(797f4846-... is the well-known app ID for Windows Azure Service Management API.) The compliant-device requirement now applied to every high-impact app automatically, SAWs were exempted by a tag they already carried, and the global block CA199 caught everything else. The audit finding closed, and the next new management app inherited the control with zero policy edits. Total policy count dropped from 40 to 19.
Verify
Validate before you enforce, every time.
- Report-only telemetry. Leave new policies in report-only for at least one business cycle. In Entra ID > Conditional Access > Insights and reporting, the workbook shows, per policy, how many sign-ins would have been blocked or challenged. A spike of “would block” on a global-block policy means your allow-list is incomplete — fix it before enforcing.
- What-If. Use the What-If tool (or the Graph
evaluateaction) to simulate a specific (user, app, device, location) tuple and confirm exactly which CAxxx policies apply and what the combined result is. Run it for one identity per persona, plus a break-glass account (which must show no policies applied). - Sign-in logs. Query the logs to confirm enforcement matches intent. The KQL below (Log Analytics / Sentinel,
SigninLogs) surfaces every sign-in a given policy enforced a control on:
SigninLogs
| where TimeGenerated > ago(7d)
| mv-expand policy = ConditionalAccessPolicies
| where tostring(policy.displayName) startswith "CA101"
| extend result = tostring(policy.result)
| summarize count() by result, tostring(policy.displayName)
| order by count_ desc
A healthy enforced policy shows success and failure rows; a row of reportOnlySuccess means it is still in report-only — promote it.