Every Entra tenant I inherit has the same Conditional Access (CA) sprawl: thirty-odd policies named like MFA test 2 FINAL, overlapping scopes nobody can reason about, a hand-curated app list that silently stopped covering new workloads two reorgs ago, and exactly one person afraid to touch any of it. CA is the load-bearing wall of a Zero Trust posture — the single enforcement point where identity signal, device state, network location, application sensitivity, and session risk are joined into an allow/block/step-up decision on every sign-in. And ad-hoc CA rots faster than almost anything else in the cloud estate, because every change is additive: someone needs an exception, they add a policy or an exclusion, and nobody ever deletes. After eighteen months you have a system whose effective behaviour can only be discovered by simulation, never by reading.
This article lays out the framework I deploy instead — the same one whether the tenant has 500 seats or 150,000. It is persona-based: every identity belongs to exactly one persona (globals, internals, admins, guests, guest-admins, developers, service accounts, workload identities), each persona has a deterministic band of policies, and the default answer to an unmodelled sign-in is deny, not allow. On top of that partition sit two mechanisms most teams under-use: authentication strengths (named, version-managed method combinations that replace the blunt mfa grant control) and authentication context (step-up bound to sensitive actions — a privileged portal blade, a labeled SharePoint site, a sensitive operation in your own app — rather than to whole applications). And the whole thing is managed as code: policies live in Git as JSON, deploy through a pipeline against Microsoft Graph, land in report-only first, and are guarded by What-If simulation and a CI gate that fails on drift.
The framework assumes you hold Conditional Access Administrator or Security Administrator, that the tenant has Entra ID P1 (CA, authentication strengths, and authentication context all require only P1 — risk-based conditions add P2), and a way to run Microsoft Graph (Graph PowerShell, the az rest shim, or a service principal in CI). By the end you can take a tenant of any size and impose a structure where, for any (user, app, device, location, risk) tuple, you can name exactly which CAxxx policies apply and why — and where a new privileged app, admin, or guest organization inherits the right controls with zero policy edits.
What problem this solves
Conditional Access is evaluated as a logical AND across every policy that matches a given sign-in: the request must satisfy all of them. That single fact is what makes ad-hoc CA so unforgiving. Two policies that both target “All users” but disagree on grant controls do not merge into something a human can hold in their head; the only way to know the effective result of any sign-in is to enumerate every matching policy and intersect their requirements. With five policies that is tedious. With thirty-five overlapping ones it is intractable, and the team’s actual mental model — “I think MFA is required for everyone, mostly” — is a hope, not a fact.
The pain shows up in four recurring failure modes. Gaps: some sign-in surface is covered by no enforcing policy — legacy auth protocols that bypass MFA, a new SaaS app nobody added to the “require compliant device” list, a guest organization that slipped in under cross-tenant defaults. Conflicts: two policies impose contradictory or accidentally-redundant controls, so either users are over-challenged (tickets pile up, admins add exclusions, and the exclusions become the new attack surface) or a block you thought was in force is silently shadowed by an exclusion three policies away. Lockout: someone ships a phishing-resistant-MFA policy without a break-glass exclusion, the rollout has a bug, and now nobody — including the person who shipped it — can get back in. Unreviewability: an auditor asks “show me that privileged access requires phishing-resistant MFA from a compliant device,” and the honest answer is a forty-minute archaeology session through policies whose names lie about what they do.
Who hits this: every organization past about 200 seats, and especially regulated ones (finance, healthcare, government) where the auditor’s question is not hypothetical. It bites hardest where the estate is heterogeneous — employees, contractors, B2B guests, guest admins (external identities holding privileged roles, a category most designs forget entirely), CI/CD service principals, and managed identities — because each population needs a different posture and the flat “All users” policy cannot express that. The fix is not “fewer policies.” It is a deterministic partition of the identity estate, a numbering scheme that makes the policy set self-documenting, a deny-by-default backstop per persona, and a change process that makes every change safe to ship.
Before the deep dive, here is the whole field in one frame — every persona this framework defines, the question its policy band answers, and the single non-negotiable control at its core:
| Persona | Who it covers | Core question its band answers | Non-negotiable control |
|---|---|---|---|
| Globals | All identities (cross-cutting floor) | What is true for everyone, no exceptions? | Block legacy auth; block unmodelled identities |
| Admins | Internal holders of privileged roles | How do humans reach the control plane? | Phishing-resistant MFA + compliant/SAW device |
| Internals | Standard employees (members) | How do staff reach productivity apps? | MFA + compliant or hybrid-joined device |
| Guests | B2B collaboration members (external members/guests) | How do partners reach shared resources? | MFA (home-tenant or here); no device control |
| Guest admins | External identities holding privileged roles | How do external admins reach the control plane? | Phishing-resistant MFA; tighter than guests |
| Developers | Engineers needing dev/test + management plane | How do developers reach Azure/DevOps tooling? | MFA + compliant device; step-up on prod actions |
| Service accounts | Non-human directory accounts that sign in interactively | How do legacy unattended accounts authenticate? | MFA-exempt on named devices only, else blocked |
| Workload identities | Service principals and managed identities | How do non-human apps authenticate? | Location + risk lockdown (workload-identity CA) |
Learning objectives
By the end of this article you can:
- Partition any identity estate into the eight personas above, express each as group membership, and explain why the partition must be exhaustive and disjoint for the AND-evaluation model to be reasoned about.
- Design a CAxxx numbering and naming scheme where the first digit encodes the persona and the last digit reserves the deny-by-default global block, so the policy set is self-documenting and sorts into its own structure.
- Build the three-layer policy matrix — grant controls, session controls, and the per-persona global block — and author each layer against Microsoft Graph v1.0 with the break-glass exclusion that every blocking or MFA policy must carry.
- Replace the blunt
mfagrant control with authentication strengths, reference the three built-in strengths by their stable GUIDs, build a custom strength, and avoid the rejection that comes from combiningmfawithauthenticationStrengthin one policy. - Implement authentication context (
c1–c25) for step-up on sensitive actions, wire it to PIM role activation, Purview sensitivity labels, and your own applications via the claims challenge, and explain why this decouples “the action is sensitive” from “which app it lives in.” - Replace brittle app and device lists with filters — filter for apps by custom security attribute, filter for devices by
deviceFilterrule — and navigate the unregistered-device null-property trap and the 3072-character rule limit. - Close the four structural gaps that defeat most CA designs: legacy authentication, device-code/authentication-transfer flows, emergency-access accounts, and the unmodelled persona.
- Manage CA as code — export the estate, deploy through CI/CD against Graph, gate on report-only telemetry and What-If, and fail the pipeline on portal drift via Microsoft365DSC or Maester.
Prerequisites & where this fits
You should already understand Entra ID fundamentals: that sign-in flows through token issuance at login.microsoftonline.com, that MFA can be satisfied by several methods (Authenticator push/passwordless, FIDO2 security keys, Windows Hello for Business, certificate-based auth, and the weaker SMS/voice), and that device state (Entra joined, hybrid joined, registered, compliant) is a signal CA can require. You should be comfortable reading and writing JSON, running Microsoft Graph calls (this article uses Graph PowerShell Connect-MgGraph and the az rest shim interchangeably), and you should know what a service principal and a managed identity are. Familiarity with PIM (Privileged Identity Management) and Intune device compliance helps but is not required.
This article sits at the top of the identity-governance stack. It assumes the building blocks are in place and shows how to compose them into a coherent estate-wide policy:
| Building block | What it provides to this framework | Where to go deeper |
|---|---|---|
| Break-glass accounts | The exclusion every blocking policy carries; the only way back from a bad rollout | Engineering Break-Glass Emergency Access Accounts in Entra ID |
| PIM | Just-in-time role activation that authentication context can gate with step-up | Privileged Identity Management and PAM Architecture |
| ID Protection | The user-risk and sign-in-risk signals that feed risk-based CA conditions (P2) | Operationalizing Entra ID Protection |
| FIDO2 / passwordless | The methods behind the phishing-resistant authentication strength | Rolling Out FIDO2 Passwordless Authentication in Entra ID |
| Workload identities | The non-human principals the workload-identity persona governs | Managed Identities Deep Dive |
| B2B / External ID | The guest and guest-admin populations and their cross-tenant trust | Securing B2B Collaboration with Entra External ID |
Where this sits in the bigger picture: CA is the enforcement layer of Zero Trust. The strategy is articulated in the Zero Trust Architecture Blueprint and the tactical CA+PIM pairing in Zero Trust on Microsoft Entra: Conditional Access + PIM. This article is the at-scale discipline that keeps that enforcement layer maintainable past the point where click-ops collapses.
Core concepts
Six mental models make every later design decision obvious. Internalize these before the policy JSON.
CA is AND-evaluated, and the AND is over policies, not controls. A sign-in collects every enabled policy whose conditions match it and must satisfy the grant controls of every one. Within a single policy’s grantControls, the operator is OR or AND (“MFA OR compliant device” vs “MFA AND compliant”). Across policies there is no operator you can set — it is always AND. This is why two well-meaning policies can over-constrain a user into a corner, and why the only reliable way to know the effective result is What-If simulation. The framework’s entire job is to make that AND predictable by ensuring policies partition cleanly instead of overlapping.
Block beats grant, always. If any matching policy returns block, the sign-in is blocked — no grant control anywhere overrides it. This is what makes the per-persona global block a deny-by-default floor: a sign-in must pass the grant policies and not be caught by any block. It is also what makes a misplaced block catastrophic — hence the break-glass exclusion on every block policy.
Every identity belongs to exactly one persona, and the partition must be exhaustive. A persona is a population with a shared posture, expressed as group membership. The disjoint property (one identity, one persona) keeps the AND tractable — you never reason about an identity being simultaneously “internal” and “guest.” The exhaustive property (every identity is in some persona) is enforced not by hope but by a tenant-wide fence policy that blocks anyone in no persona group. Without it, an unmodelled identity hits no policy and therefore no block — the single most dangerous gap in any CA design.
Grant, session, and block are three layers. Grant controls answer “what must you prove to get in?” (MFA, compliant device, authentication strength, terms of use). Session controls answer “what constraints apply once you’re in?” (sign-in frequency, persistent-browser, app-enforced restrictions, CAE, token protection). The global block is the floor — deny-by-default. Thinking in these three layers per persona turns a pile of rules into a matrix you fill in deliberately.
Authentication strength is a named requirement; authentication context is a named target. A strength replaces mfa in grantControls with a version-managed combination of methods (e.g. “phishing-resistant” = FIDO2 key OR Windows Hello OR certificate). An authentication context is an opaque ID (c1–c25) you put in conditions.applications.includeAuthenticationContextClassReferences so the policy targets a sensitive action rather than an app — the ID rides in the token’s acrs claim and resources demand it to trigger step-up. They compose: a step-up policy targets a context (c5) and grants on a strength (...004).
Filters replace lists. A hand-maintained list of app or device IDs goes stale the moment something new is created. A filter for apps targets apps by a custom security attribute; a filter for devices (conditions.devices.deviceFilter) targets devices by a rule over device properties. Tag once, and new objects inherit the policy with no policy edit. The trap, covered below: unregistered devices have null properties, so a positive operator never matches them.
The vocabulary in one table
Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this is the model side by side:
| Concept | One-line definition | Where it lives | Why it matters at scale |
|---|---|---|---|
| Policy | One CA rule: conditions → grant/session controls | identity/conditionalAccess/policies |
AND-evaluated with every other matching policy |
| Persona | A population with a shared posture | An Entra group | The unit of partition; one identity → one persona |
| Grant control | What you must prove to get in | grantControls |
block here overrides every grant anywhere |
| Session control | Constraint once you are in | sessionControls |
Sign-in frequency, persistent browser, CAE, token protection |
| Global block | Deny-by-default floor for a persona | The xx9 policy |
Makes “forgot to model it” = “denied”, not “allowed” |
| Authentication strength | Named method combination | grantControls.authenticationStrength |
Replaces blunt mfa; versioned; 3 built-ins + custom |
| Authentication context | Named sensitive-action target (c1–c25) |
includeAuthenticationContextClassReferences |
Step-up by action, not by app; ID rides in acrs |
| Filter for apps | Target apps by custom security attribute | applications.applicationFilter |
New apps inherit policy by tag, no edit |
| Filter for devices | Target devices by a rule | devices.deviceFilter |
New devices inherit by property; 3072-char cap |
| Break-glass exclusion | Emergency accounts excluded everywhere | users.excludeGroups |
The only way back from a bad rollout |
| Report-only | Policy evaluated but not enforced | state = enabledForReportingButNotEnforced |
Mandatory first state for every new/changed policy |
| What-If | Simulate a (user, app, device, …) tuple |
Portal tool / Graph evaluate | Confirms which CAxxx apply before enforcing |
The states a policy can be in
A policy’s state is the single most operationally important field in this entire framework, because it is the gate between “I designed it” and “it is now blocking real users.” There are exactly three values, and the discipline is non-negotiable:
state value |
Meaning | What the user experiences | When you use it |
|---|---|---|---|
enabledForReportingButNotEnforced |
Evaluated, logged, not enforced | Nothing — sign-in proceeds as if the policy were absent | Always first. Every new or changed policy lands here |
enabled |
Evaluated and enforced | The grant/session/block control applies | Only after report-only telemetry is clean |
disabled |
Not evaluated at all | Nothing | Decommissioned policies you keep for audit history |
The rule that prevents most CA lockouts: new and changed policies deploy in enabledForReportingButNotEnforced first, and a human promotes to enabled only after the report-only telemetry is clean. This is so important it is wired into the CI/CD gate later — the pipeline refuses to promote a policy whose report-only window shows unexpected would-block events.
Define the personas and the numbering scheme
Start by partitioning every account into a persona. The eight below cover essentially every tenant I have seen. The first five are the load-bearing ones; the last three (guest admins, developers, service accounts) are the categories teams most often forget — and forgetting them is where the gaps come from.
| Persona | Who | Membership source | Backstop posture |
|---|---|---|---|
| Globals | Cross-cutting floor over all identities | “All users” / “All guests” with persona-group exclusions | Block legacy auth; block unmodelled identities |
| Admins | Internal holders of privileged roles, PIM-eligible | Assigned group, fed from PIM-eligible role holders | Phishing-resistant MFA + compliant/SAW device, always |
| Internals | Standard employees (member user type) | Dynamic group userType -eq "Member" minus admins |
MFA + compliant or hybrid-joined device |
| Guests | B2B collaboration users (guest user type) | Dynamic group userType -eq "Guest" minus guest-admins |
MFA (trust home-tenant MFA where agreed); no device control |
| Guest admins | External identities holding privileged roles | Assigned group, fed from external role holders | Phishing-resistant MFA; no home-tenant MFA trust; tighter than guests |
| Developers | Engineers needing dev/test + Azure/DevOps tooling | Assigned group | MFA + compliant device; step-up (auth context) on prod/management actions |
| Service accounts | Non-human directory accounts that still sign in interactively | Assigned group | MFA-exempt on named devices only; otherwise blocked |
| Workload identities | Service principals and managed identities | Workload-identity CA (separate object type) | Location lockdown + (P2) risk; no interactive MFA concept |
Two design choices in that table are worth dwelling on. First, use assigned groups for break-glass-sensitive personas (Admins, Guest admins, Service accounts) so a dynamic-rule misconfiguration cannot silently empty the scope — a typo’d “Admins” rule that resolves to zero members would apply your phishing-resistant-MFA policy to nobody, and you would not notice until an incident. Dynamic groups are fine for Internals and Guests, where the rule is simple and the failure mode (everyone or no one) is loud. Second, Workload identities are a different object type entirely — service principals are not users, do not do interactive MFA, and are governed by workload-identity Conditional Access (a Workload Identities Premium add-on capability) conditioning on location and, with the add-on, risk. They get their own band but a fundamentally different policy shape.
The numbering scheme
Assign a numeric band per persona and a slot per policy function within it. The first digit encodes the persona; the last digit 9 is reserved for that persona’s global block. Here is the full scheme:
| Band | Persona | Example function slots |
|---|---|---|
CA001–CA099 |
Globals (all personas) | CA001 block legacy auth; CA002 block unmodelled identities; CA003 block device-code flow; CA004 block from unsupported platforms; CA005 require ToU |
CA100–CA199 |
Admins | CA101 require phishing-resistant MFA; CA102 require compliant/SAW device; CA103 step-up on c5; CA104 sign-in frequency 4h + no persistent browser; CA199 global block |
CA200–CA299 |
Internals | CA201 require MFA; CA202 require compliant/hybrid device; CA203 block downloads on unmanaged (app-enforced); CA204 sign-in-risk step-up (P2); CA299 global block |
CA300–CA399 |
Developers | CA301 require MFA + compliant; CA302 step-up on c6 (prod/management actions); CA303 sign-in frequency on Azure mgmt; CA399 global block |
CA400–CA499 |
Guests | CA401 guest MFA strength; CA402 guest sign-in frequency; CA403 block guest access to admin portals; CA499 global block |
CA500–CA599 |
Guest admins | CA501 phishing-resistant MFA (no home-tenant trust); CA502 step-up on c5; CA599 global block |
CA600–CA699 |
Service accounts | CA601 block except named devices; CA602 block from outside trusted locations; CA699 global block |
CA700–CA799 |
Workload identities | CA701 block service principal sign-in outside named locations; CA702 block risky workload identities (P2) |
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 where anything you forget to model lands. Name policies CAxxx-<persona>-<function>, e.g. CA101-Admins-Require-PhishResistant-MFA: the number sorts them into bands in the portal; the suffix tells a reviewer what it does without opening it. Adopt the convention rigorously — a single MFA test 2 FINAL defeats the self-documenting property for the whole tenant.
A few naming rules that pay off at audit time and during incidents:
| Rule | Why it matters |
|---|---|
| First digit = persona band | The portal’s alphabetical sort becomes a persona-grouped sort; reviewers see structure |
Last digit 9 = global block |
Anyone scanning the list can find the deny-by-default floor for any persona instantly |
| Suffix is a verb phrase, not a date | Require-PhishResistant-MFA, not v3-final; the name states intent, not history |
Report-only policies prefix with RO- (optional) |
While piloting, RO-CA204-… flags “not yet enforced” at a glance in the list |
| Never reuse a retired number | Set retired policies to disabled, keep the number; reuse breaks audit traceability |
Mapping personas to groups
Personas are groups, so membership rules deserve care. The rules I use, with the trade-off each carries:
| Persona | Group type | Membership rule / source | Failure mode to guard against |
|---|---|---|---|
| Admins | Assigned | Synced from PIM-eligible role holders by a scheduled job | Dynamic typo → empty scope → no MFA enforcement (so: assigned) |
| Guest admins | Assigned | External role holders, curated | Same; external admins are the highest-risk population |
| Service accounts | Assigned | Hand-curated, reviewed quarterly via access review | Sprawl; a forgotten service account is an exempt back door |
| Developers | Assigned | Membership tied to an entitlement-management access package | Standing membership; prefer time-bound via access packages |
| Internals | Dynamic | (user.userType -eq "Member") and (user.accountEnabled -eq true) |
Rule must exclude admins/devs/service-accts or personas overlap |
| Guests | Dynamic | (user.userType -eq "Guest") |
Must exclude guest-admins or the wrong (looser) posture wins |
| Workload identities | n/a | Targeted in workload-identity CA by SP object/group | Not a user group; different policy object |
The exclusion logic between dynamic personas matters: because CA is AND-evaluated, an identity in both “Internals” and “Admins” would get both bands’ policies — which muddies reasoning and can over-constrain. Keep the partition disjoint by excluding the assigned personas from the dynamic-group rules (Internals = members and not a member of the Admins/Developers/Service-accounts groups). Disjointness is what lets you say “this user is an Internal, therefore exactly the CA2xx policies apply.”
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). I will author all three for the Internals persona, then generalize. Every blocking or MFA policy carries the break-glass exclusion — that is non-negotiable and appears in every snippet.
Layer 1 — grant controls
Here is the Internals require-MFA policy authored against Microsoft Graph v1.0, landing in report-only as the discipline demands:
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess","Policy.Read.All"
$params = @{
displayName = "CA201-Internals-Require-MFA"
state = "enabledForReportingButNotEnforced" # report-only first, always
conditions = @{
users = @{
includeGroups = @("<internals-group-id>")
excludeGroups = @("<breakglass-group-id>") # never omit this
}
applications = @{ includeApplications = @("All") }
clientAppTypes = @("all")
}
grantControls = @{
operator = "OR"
builtInControls = @("mfa")
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params
The grantControls block is where most of the design decisions live. Here is the full menu of built-in controls and how to reason about each:
builtInControls value |
What it requires | Typical persona | Gotcha |
|---|---|---|---|
mfa |
Any second factor | Internals (baseline) | Blunt; superseded by authenticationStrength for precision |
compliantDevice |
Intune-compliant device | Internals, Developers | Requires Intune; device must be enrolled and compliant |
domainJoinedDevice |
Hybrid Entra joined | Internals (on-prem estate) | Hybrid-join only; not Entra-joined-only devices |
compliantApplication |
Approved client app (app protection) | Mobile-heavy estates | Requires app-protection policies (MAM) |
approvedApplication |
Approved client app | Mobile | Legacy; prefer compliantApplication |
passwordChange |
Force a password reset | Risk remediation (P2) | Only with user-risk condition; user must be able to MFA |
block |
Deny the sign-in | Global blocks | Overrides every grant; carries break-glass exclusion |
unknownFutureValue |
(reserved) | — | Do not set; placeholder for forward-compat |
And the operator between them — the one knob that changes “require any of” into “require all of”:
operator |
Semantics | Example |
|---|---|---|
OR |
Satisfy any listed control | “MFA or compliant device” — common for Internals (BYOD-friendly) |
AND |
Satisfy all listed controls | “MFA and compliant device” — common for Admins (strict) |
Note that authenticationStrength is not a member of builtInControls; it is a sibling property of grantControls (covered in the next major section). The two are mutually exclusive in a way that trips people up: you cannot list mfa in builtInControls and specify an authenticationStrength, because the MFA built-in is the strength …002 and the platform rejects the redundancy.
Layer 2 — session controls
Session controls constrain the authenticated session rather than gating entry. They live under sessionControls:
sessionControls = @{
signInFrequency = @{
isEnabled = $true
type = "hours"
value = 4
frequencyInterval = "timeBased"
}
persistentBrowser = @{
isEnabled = $true
mode = "never" # no persistent cookies on unmanaged browsers
}
}
The full set of session controls, what each does, and where it earns its place:
| Session control | What it constrains | Key values | When to use |
|---|---|---|---|
signInFrequency |
How often re-auth is forced | type hours/days, or frequencyInterval: everyTime |
High-value apps; admin sessions (4h); SCIM/CAE alternatives |
persistentBrowser |
“Stay signed in?” cookie persistence | mode: always / never |
never on unmanaged devices to avoid lingering sessions |
cloudAppSecurity |
Routes session through Defender for Cloud Apps | monitorOnly / blockDownloads / custom |
Inline session control (download/upload blocking) |
applicationEnforcedRestrictions |
App-level limited web access (SPO/Exchange) | isEnabled |
Block download/print on unmanaged for Office apps |
disableResilienceDefaults |
Opt out of CA resilience (fail-open) | isEnabled |
Rarely; trades availability for strictness |
continuousAccessEvaluation |
Near-real-time token revocation | mode: strictEnforcement |
Critical apps; revoke on risk/location change in minutes |
secureSignInSession (token protection) |
Bind token to the device (sign-in session) | isEnabled |
Phishing-token-theft mitigation; Windows-first |
signInFrequency deserves a sub-table because its parameters are easy to misuse — and an overly aggressive value is a top driver of user friction and ticket volume:
signInFrequency field |
Values | Effect | Trade-off |
|---|---|---|---|
type + value |
hours / days + integer |
Re-auth after that interval | Too short → constant prompts; too long → stale sessions |
frequencyInterval |
timeBased / everyTime |
everyTime forces re-auth on each access (auth-context) |
everyTime only sensible bound to a sensitive auth context |
authenticationType (beta) |
primaryAndSecondaryAuthentication |
Whether MFA re-prompts too | Default re-prompts MFA; tune for UX |
Layer 3 — the global block
The global block (CA299 for Internals) 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 and block beats grant, 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>") # consciously approved
}
clientAppTypes = @("all")
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $block
A subtlety that catches teams off guard: managing the approved-app exclusion by enumerating object IDs is itself a rot vector — exactly the problem filters solve. The mature pattern drives the block’s exclusion by a filter for apps (an approved tag) rather than a hand-curated excludeApplications array, so newly-approved apps are exempted by being tagged.
The full matrix, filled in for the core personas, looks like this — read it as the design you are implementing, one row per policy:
| CAxxx | Persona | Layer | Grant / session / block | Key condition |
|---|---|---|---|---|
CA101 |
Admins | Grant | Auth strength …004 (phishing-resistant) |
All apps |
CA102 |
Admins | Grant | compliantDevice (or SAW via device filter) |
All apps; exclude SAW filter |
CA103 |
Admins | Grant | Auth strength …004 on c5 |
Auth context c5 |
CA104 |
Admins | Session | Sign-in frequency 4h; persistent browser never | All apps |
CA199 |
Admins | Block | Block all except approved-app filter | All apps minus highImpact/approved |
CA201 |
Internals | Grant | mfa |
All apps |
CA202 |
Internals | Grant | compliantDevice OR domainJoinedDevice |
All apps |
CA203 |
Internals | Session | App-enforced restrictions (no download unmanaged) | SPO/Exchange |
CA299 |
Internals | Block | Block all except approved-app filter | All apps minus approved |
CA401 |
Guests | Grant | Guest auth strength (MFA) | All apps |
CA403 |
Guests | Block | Block admin portals | Azure mgmt / admin apps |
CA499 |
Guests | Block | Block all except shared-collab apps | All apps minus collab |
CA501 |
Guest admins | Grant | Auth strength …004, no home-tenant trust |
All apps |
CA601 |
Service accts | Block | Block except named devices | All apps; exclude device filter |
CA001 |
Globals | Block | Block legacy auth | exchangeActiveSync, other |
CA002 |
Globals | Block | Block unmodelled identities | All users minus all persona groups |
Authentication strengths: from blunt MFA to named requirements
Authentication strength replaces the blunt mfa grant control with a named, version-managed combination of methods. Instead of “any second factor” (which includes SMS, the weakest and most phishable), you require a specific set — “phishing-resistant only” for admins, “passwordless or better” for internals, “anything but SMS” for a tuned baseline. There are three built-ins, referenced by stable GUID, plus custom strengths you author yourself.
| Built-in strength | GUID | Methods it accepts |
|---|---|---|
| Multifactor authentication | 00000000-0000-0000-0000-000000000002 |
Any MFA combination (push, OATH, SMS, voice, FIDO2, WHfB, CBA) |
| Passwordless MFA | 00000000-0000-0000-0000-000000000003 |
Authenticator passwordless, FIDO2, WHfB, CBA (multi-factor) |
| Phishing-resistant MFA | 00000000-0000-0000-0000-000000000004 |
FIDO2 security key, Windows Hello for Business, certificate-based (multi-factor) |
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 the API rejects it:
$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) — the built-in filter lives on the beta endpoint:
az rest --method GET \
--uri "https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies?\$filter=policyType eq 'builtIn'"
Choosing the right strength per persona
The built-ins are a starting point; the mapping to personas is the design — here is the matrix I deploy, with the migration trap for each:
| Persona | Strength | Why | Migration trap |
|---|---|---|---|
| Admins | Phishing-resistant (…004) |
Privileged access is the prime phishing target | Users without a FIDO2 key/WHfB get locked out — pilot in report-only, roll keys first |
| Guest admins | Phishing-resistant (…004) |
External privileged identities, highest risk | Guest may have no method here; require registration via TAP or external-tenant trust |
| Developers | Custom (any but SMS) for baseline; phishing-resistant on c6 step-up |
Balance friction with strong step-up on prod actions | Custom strength must permit a method every dev actually has |
| Internals | Custom (any but SMS) or Passwordless (…003) where rolled out |
Kill SMS phishing without locking out the long tail | Passwordless requires WHfB/Authenticator passwordless enrollment first |
| Guests | Built-in MFA (…002), or trust home-tenant MFA |
Cannot mandate methods you do not control in their tenant | Home-tenant MFA trust must be enabled in cross-tenant access settings |
| Service accounts | n/a (blocked except named devices) | Non-interactive; MFA is the wrong tool | Do not put service accounts on a phishing-resistant strength — they cannot satisfy it |
Building a custom strength
When the built-ins do not fit — most commonly “any MFA except SMS and voice” to kill the phishable methods without a full passwordless rollout — author a custom strength. It is its own object with an allowedCombinations array, referenced by its generated GUID exactly like a built-in:
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/authenticationStrength/policies" \
--headers "Content-Type=application/json" \
--body '{
"displayName": "Any MFA except SMS/Voice",
"description": "Push, OATH, FIDO2, WHfB, CBA — excludes phishable telecom methods",
"allowedCombinations": [
"windowsHelloForBusiness",
"fido2",
"x509CertificateMultiFactor",
"deviceBasedPush",
"password,microsoftAuthenticatorPush",
"password,softwareOath",
"password,hardwareOath"
]
}'
The allowedCombinations vocabulary is finite and specific. The combinations worth knowing:
| Combination token | Method it represents | Phishing-resistant? |
|---|---|---|
fido2 |
FIDO2 security key | Yes |
windowsHelloForBusiness |
Windows Hello for Business | Yes |
x509CertificateMultiFactor |
Certificate-based auth (multi-factor) | Yes |
x509CertificateSingleFactor |
CBA single factor | No (single factor) |
deviceBasedPush |
Authenticator passwordless (phone sign-in) | Yes |
password,microsoftAuthenticatorPush |
Password + Authenticator push | No (push-bombable) |
password,softwareOath |
Password + software OATH (TOTP) | No |
password,hardwareOath |
Password + hardware OATH token | No |
password,sms |
Password + SMS | No (phishable) |
password,voice |
Password + voice call | No (phishable) |
federatedMultiFactor |
Federated IdP MFA | Depends on IdP |
federatedSingleFactor |
Federated IdP single factor | No |
A strength can also declare which combinations are honored for external/B2B users separately, and home-tenant MFA can count toward a strength when cross-tenant trust is configured — relevant for the Guests and Guest-admins personas, where you cannot mandate a method you do not control.
Authentication context: step-up for sensitive actions
Authentication context is the other half of step-up, and the more powerful one at scale. 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, a PIM role activation. 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. One context, defined once, can be wired to many sensitive actions across many apps — the decoupling is the whole point.
Create a context 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" } # phishing-resistant
}
}
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. The mechanics differ by binding point:
Where c5 is demanded |
How you bind it | What triggers step-up | Notes |
|---|---|---|---|
| PIM role activation | PIM role settings → “On activation, require Conditional Access authentication context” → select c5 |
Activating an eligible privileged role | Forces phishing-resistant MFA at activation time, not just sign-in |
| Microsoft Entra admin center actions | Built-in: certain sensitive admin actions can be protected by auth context | Performing the protected action | Tenant-level sensitive-action protection (preview-gated features vary) |
| Purview sensitivity labels | Label policy → access control → require c5 for labeled content |
Opening a site/document with the label | Labels on SharePoint sites/Teams; step-up to open |
| SharePoint / OneDrive site | Site-level: assign auth context to a site | Accessing the site | Per-site step-up without a per-site CA policy |
| Defender for Cloud Apps session | Session policy referencing the context | In-session sensitive action | Inline, near-real-time |
| Your own application | App requests claims challenge for acrs value c5 |
A sensitive operation in your code | The app issues a claims challenge; CA satisfies it; token now carries acrs: c5 |
The claims-challenge flow for your own apps
The most powerful and least-used binding is your own application demanding a context for a sensitive operation. The flow: your API checks the incoming token for acrs containing c5; if absent, it returns a claims challenge (insufficient_claims with a claims parameter); the client redirects through interactive auth carrying that parameter; CA applies CA103 and forces phishing-resistant MFA; the new token carries acrs: c5; the operation proceeds. Skeleton of the API-side check and challenge:
// Pseudocode — your API gating a sensitive operation on auth context "c5"
var acrs = User.FindAll("acrs").Select(c => c.Value);
if (!acrs.Contains("c5"))
{
// Build the claims challenge demanding the auth context, return 401 with it
var claimsChallenge =
"{\"access_token\":{\"acrs\":{\"essential\":true,\"value\":\"c5\"}}}";
Response.Headers["WWW-Authenticate"] =
$"Bearer error=\"insufficient_claims\", claims=\"{Base64Url(claimsChallenge)}\"";
return Unauthorized();
}
// acrs contains c5 → user has stepped up → proceed with the sensitive operation
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. The alternative, a step-up CA policy per app, makes every new sensitive app a policy change, a review, a deploy, and an opportunity to forget one. With contexts, the policy set stays flat as the sensitive-action surface grows.
A reference table of the contexts I provision in a typical enterprise, so the IDs carry consistent meaning across the estate:
| Context ID | Friendly name | Bound to | Required strength |
|---|---|---|---|
c1 |
High-impact data access | Purview “Highly Confidential” label | Phishing-resistant |
c2 |
Finance/wire operations | Custom finance app sensitive ops | Phishing-resistant |
c5 |
Privileged admin actions | PIM activation; admin portal sensitive actions | Phishing-resistant |
c6 |
Production/management plane | Developer prod deployments; Azure mgmt | Phishing-resistant |
c10 |
HR/PII access | HR app; PII-labeled SharePoint | Passwordless or better |
c15 |
Legal hold / eDiscovery | Purview eDiscovery roles | Phishing-resistant |
Filters for devices and apps to replace brittle lists
Hand-maintained app and device lists are the most common rot vector in CA. Two filter mechanisms kill them, and getting them right is the difference between a design that ages well and one that quietly develops gaps.
Filter for apps
Filter for apps lets you target apps by a custom security attribute instead of enumerating object IDs. You define an attribute (for example an attribute set CASecurity with attribute highImpact), tag apps with it once (CASecurity/highImpact = true), and write the policy against the tag. New apps inherit the policy by being tagged, with no policy change. First define the attribute set and attribute:
# Create a custom security attribute set, then an attribute within it
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/directory/attributeSets" \
--headers "Content-Type=application/json" \
--body '{"id":"CASecurity","description":"CA targeting attributes","maxAttributesPerSet":10}'
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/directory/customSecurityAttributeDefinitions" \
--headers "Content-Type=application/json" \
--body '{
"attributeSet":"CASecurity",
"name":"highImpact",
"description":"App is high-impact; CA requires compliant device",
"type":"Boolean",
"status":"Available",
"isCollection":false,
"isSearchable":true,
"usePreDefinedValuesOnly":false
}'
Then the policy targets the filter rather than an app list:
{
"displayName": "CA102-Admins-Require-Compliant-On-HighImpact",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"users": {
"includeGroups": ["<admins-group-id>"],
"excludeGroups": ["<breakglass-group-id>"]
},
"applications": {
"includeApplications": ["All"],
"applicationFilter": {
"mode": "include",
"rule": "CustomSecurityAttribute.CASecurity_highImpact -eq \"True\""
}
},
"clientAppTypes": ["all"]
},
"grantControls": { "operator": "OR", "builtInControls": ["compliantDevice"] }
}
Tagging requires the Attribute Assignment Administrator role (and Attribute Definition Administrator to define the schema) — deliberately separate from CA admin, so attribute governance is its own least-privilege boundary.
Filter for devices
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 over device properties. 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"] }
}
The operators available in a device filter rule:
| Operator | Meaning | Example |
|---|---|---|
-eq / -ne |
Equals / not equals | device.trustType -eq "ServerAD" |
-in / -notIn |
In / not in a set | device.deviceOwnership -in ["Company"] |
-contains / -notContains |
Substring contains | device.displayName -contains "SAW" |
-startsWith / -notStartsWith |
Prefix | device.enrollmentProfileName -startsWith "Corp" |
-endsWith / -notEndsWith |
Suffix | device.extensionAttribute1 -endsWith "-prod" |
-and / -or |
Boolean combination | (device.isCompliant -eq True) -and (device.trustType -eq "ServerAD") |
And the device properties you can filter on, with the values and the gotcha for each:
| Property | Values / type | Gotcha |
|---|---|---|
trustType |
AzureAD (Entra joined), ServerAD (hybrid joined), Workplace (registered) |
The labels are non-obvious; ServerAD = hybrid, not “on-prem only” |
isCompliant |
True / False |
Null on unregistered devices — positive operator won’t match them |
deviceOwnership |
Company / Personal |
Set at enrollment; unmanaged devices have no value |
deviceId |
GUID | Enumerating these is the brittleness you are trying to avoid |
displayName |
String | Renameable; not a stable identifier |
enrollmentProfileName |
String (Autopilot/ABM profile) | Only for devices enrolled via a named profile |
manufacturer / model / operatingSystem / operatingSystemVersion |
Strings | Useful for platform/hardware fences |
extensionAttribute1–15 |
Strings | Populate only for Intune-managed, compliant, or hybrid-joined devices |
physicalIds / systemLabels |
Strings | Advanced; rarely needed |
The two traps that bite
Two failure modes in device filters are subtle enough to cause real outages or silent gaps:
The null-property trap. For unregistered devices, all properties are null. So a positive operator (-eq) never matches an unregistered device. If your intent is “exclude approved devices from a block” and an unregistered device has no extensionAttribute2, the exclude does not match → the device is not excluded → the block applies — usually what you want. But a filter that expects to catch unregistered devices with a positive operator silently misses them. Express exclusions with a property the device must positively have and let the global block catch the null case — or use a negative operator (-ne, -notStartsWith) to deliberately target unregistered devices.
The extensionAttribute population trap. extensionAttribute1-15 populate only for Intune-managed, compliant, or hybrid-joined devices. Tagging a BYOD/registered-only device with an extension attribute and filtering on it will not work — the attribute is never read for that device class. For mixed estates, prefer trustType, isCompliant, or deviceOwnership as the filter basis, and reserve extension attributes for the managed fleet.
| Trap | Symptom | Root cause | Correct pattern |
|---|---|---|---|
| Null-property | Filter silently doesn’t apply to some devices | Unregistered devices have all-null properties | Positive operator for “must have”; let global block catch nulls; negative operator to target nulls |
| Extension-attribute population | Tag set but filter never matches | Extension attrs only read for managed/compliant/hybrid devices | Use trustType/isCompliant/deviceOwnership for mixed estates |
| 3072-char rule cap | API rejects the policy | Enumerated device IDs blew the rule length | Tag and filter by attribute, never enumerate IDs |
Renamed displayName |
Filter stops matching after a rename | displayName is not stable |
Filter on stable properties (trustType, extension attrs), not names |
The rule string caps at 3072 characters — which is, not coincidentally, exactly why you tag rather than enumerate device IDs: a list of GUIDs blows the cap, while a tag-based rule is a single short expression that scales to any number of devices.
Closing the four structural gaps
Four gaps defeat most CA designs no matter how good the persona policies are. Each is a single high-value policy, and together they are the framework’s outer fence.
Gap 1 — Legacy authentication
Basic-auth protocols (older Exchange, IMAP/POP, SMTP AUTH, older Office clients) authenticate with a username and password and cannot satisfy an interactive MFA challenge — they bypass MFA entirely. This is the single highest-value policy in the tenant. Ship CA001-Global-Block-Legacy-Auth targeting the legacy client-app types with a block:
{
"displayName": "CA001-Global-Block-Legacy-Auth",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"users": { "includeUsers": ["All"], "excludeGroups": ["<breakglass-group-id>"] },
"applications": { "includeApplications": ["All"] },
"clientAppTypes": ["exchangeActiveSync", "other"]
},
"grantControls": { "operator": "OR", "builtInControls": ["block"] }
}
The clientAppTypes values and what each captures:
clientAppTypes value |
What it matches | Notes |
|---|---|---|
browser |
Modern browser SSO | Modern auth |
mobileAppsAndDesktopClients |
Modern auth desktop/mobile apps | Modern auth |
exchangeActiveSync |
EAS clients (older mail) | Often legacy; some modern EAS exists |
other |
Legacy protocols: IMAP, POP, SMTP AUTH, older Office | The catch-all for basic auth |
all |
All of the above | Default when you don’t restrict |
Read the report-only telemetry to find which service accounts and devices still use legacy auth before you enforce — enforcing blind breaks that overnight SMTP relay nobody documented.
Gap 2 — Device code flow and authentication transfer
Phishing campaigns increasingly abuse device-code flow and cross-device (authentication-transfer) flows: the attacker initiates a device-code sign-in and social-engineers the victim into entering the code, harvesting a token. Block these for personas that never legitimately need them via the authentication flows condition:
{
"displayName": "CA003-Global-Block-DeviceCode-And-Transfer",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"users": { "includeUsers": ["All"], "excludeGroups": ["<breakglass-group-id>", "<kiosk-iot-exception-group-id>"] },
"applications": { "includeApplications": ["All"] },
"authenticationFlows": { "transferMethods": "deviceCodeFlow,authenticationTransfer" }
},
"grantControls": { "operator": "OR", "builtInControls": ["block"] }
}
Exclude the handful of legitimate kiosk/IoT/console scenarios (which genuinely need device-code flow) by a device-filter or a small exception group — do not leave it open for everyone because of a few edge devices.
Gap 3 — Emergency-access (break-glass) accounts
Two cloud-only break-glass accounts with long passphrases stored offline, excluded from every policy, and wired to a high-severity 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. This is so important it has its own deep treatment in Engineering Break-Glass Emergency Access Accounts in Entra ID — the short version of the contract:
| Break-glass requirement | Why |
|---|---|
Cloud-only (*.onmicrosoft.com), not federated |
A federation outage must not lock out the recovery path |
| Excluded from every CA policy via a dedicated group | Any single enforcing policy with a bug could otherwise block them |
| Long random passphrase, split and stored offline | Not phishable; not in any password manager an attacker can reach |
| No standing MFA method that can fail (or a dedicated FIDO2 key in a safe) | A broken MFA method must not be the lockout cause |
| High-severity alert on any sign-in | These accounts should almost never sign in; one is an event |
| Permanent Global Administrator (not PIM-eligible) | PIM activation could itself be blocked during an incident |
| Excluded but monitored for membership change | An attacker adding themselves to the exclusion group is a critical event |
The exclusion is a group, not individual accounts, so a new break-glass account is added to the group once and is instantly excluded from all policies — and the framework’s “every block carries excludeGroups” rule keeps the contract intact as policies multiply.
Gap 4 — The unmodelled persona
Anyone not in a persona group hits no persona policy — and therefore no global block. This is the most dangerous gap because it is invisible: a newly created account, a new external identity, a freshly registered service principal, all land in the “covered by nothing” set until someone remembers to add them to a persona group. Add a tenant-wide CA002 that blocks all apps for “All users” excluding the union of every persona group plus break-glass. It is the framework’s outermost fence:
{
"displayName": "CA002-Global-Block-Unmodelled-Identities",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"users": {
"includeUsers": ["All"],
"excludeGroups": [
"<admins-group-id>", "<internals-group-id>", "<developers-group-id>",
"<guests-group-id>", "<guest-admins-group-id>", "<svc-accounts-group-id>",
"<breakglass-group-id>"
]
},
"applications": { "includeApplications": ["All"], "excludeApplications": ["<security-registration-app-ids>"] },
"clientAppTypes": ["all"]
},
"grantControls": { "operator": "OR", "builtInControls": ["block"] }
}
Exclude the security-info-registration and MFA-registration endpoints from CA002 so a brand-new user can still register their first method before they have been slotted into a persona — otherwise you create a chicken-and-egg lockout for onboarding. This policy is the one most likely to surprise you in report-only (it catches every identity your persona groups missed), which is exactly why it is invaluable: the report-only would-block count is your list of unmodelled identities to triage.
The four gaps as a closing reference:
| Gap | Policy | What it catches | The exclusion it needs |
|---|---|---|---|
| Legacy auth | CA001 |
Basic-auth protocols bypassing MFA | Break-glass; documented legacy service accounts (temporarily) |
| Device-code/transfer | CA003 |
Cross-device phishing flows | Break-glass; kiosk/IoT exception group |
| Break-glass | (the exclusion itself) | Lockout from a bad rollout | n/a — it is the exclusion |
| Unmodelled persona | CA002 |
Identities in no persona group | All persona groups + break-glass + registration apps |
Managing CA as code with Graph and CI/CD
Click-ops does not scale across eight personas and forty-plus policies. Treat policies as JSON in Git, deploy through a pipeline against Microsoft Graph, gate on report-only and What-If, and fail the pipeline on portal drift — the operational discipline that keeps the framework from rotting the way the estate you inherited did.
Export the current estate
First, capture what exists — both as a backup and as the baseline your repo represents:
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. Restore creates from the stored definitions, but you must strip read-only fields (id, createdDateTime, modifiedDateTime, templateId) first or the create call rejects them:
# Backup the full estate, timestamped
$pol = Get-MgIdentityConditionalAccessPolicy -All
$pol | ConvertTo-Json -Depth 12 | Out-File "ca-backup-$(Get-Date -f yyyyMMdd-HHmm).json"
# Restore one policy from a sanitized definition in the repo
$def = Get-Content "./policies/CA101-Admins-Require-PhishResistant-MFA.json" -Raw | ConvertFrom-Json
# Ensure read-only fields are absent before create
$def.PSObject.Properties.Remove('id')
$def.PSObject.Properties.Remove('createdDateTime')
$def.PSObject.Properties.Remove('modifiedDateTime')
New-MgIdentityConditionalAccessPolicy -BodyParameter $def
The read-only fields that must be stripped before any create/update, and why:
| Field | Why it’s read-only | What happens if you send it |
|---|---|---|
id |
Server-assigned policy GUID | Create rejects it; update uses it in the URL, not the body |
createdDateTime |
Server timestamp | Rejected on create |
modifiedDateTime |
Server timestamp | Rejected on create |
templateId |
Set only when created from a template | Rejected on create from a hand-authored definition |
@odata.context |
Response metadata | Not a policy field; strip it |
The CI/CD pipeline shape
The pipeline authenticates as a service principal holding Policy.ReadWrite.ConditionalAccess via a federated credential (no secret — see Building a Secure OIDC Confidential Client in Entra ID for the setup), then runs a deploy that is report-only by construction:
# Azure DevOps / GitHub Actions shape (illustrative)
stages:
- stage: Validate
jobs:
- job: Lint
steps:
- script: |
# Assert every policy JSON has a break-glass exclusion and lands report-only
python tools/ca-lint.py policies/*.json
- stage: DeployReportOnly
dependsOn: Validate
jobs:
- job: Deploy
steps:
- script: |
# OIDC-federate to Entra, deploy each policy as enabledForReportingButNotEnforced
pwsh tools/deploy-ca.ps1 -State enabledForReportingButNotEnforced
- stage: PromoteToEnforced
dependsOn: DeployReportOnly
condition: and(succeeded(), eq(variables['promote'], 'true'))
jobs:
- deployment: Promote
environment: ca-production # gated by manual approval
strategy:
runOnce:
deploy:
steps:
- script: pwsh tools/deploy-ca.ps1 -State enabled
The non-negotiable rules the pipeline enforces, each as a lint check that fails the build:
| CI guard | What it checks | Why it fails the build |
|---|---|---|
| Break-glass exclusion present | Every block/MFA policy excludeGroups contains the break-glass group |
A policy without it is a lockout risk |
| Report-only on first deploy | New/changed policy state is enabledForReportingButNotEnforced |
Enforcing untested is how outages happen |
| Naming convention | displayName matches CA\d{3}-<Persona>-<Function> |
Off-scheme names break self-documentation |
| No enumerated device/app IDs | Policies use filters, not ID arrays (beyond approved exceptions) | ID lists rot and hit the 3072 cap |
| Promotion is manual + gated | enabled only via a gated environment with approval |
A human must own the enforce decision |
| Drift check | Deployed state matches repo (Microsoft365DSC/Maester) | Portal changes at 2 a.m. must fail the gate, not silently win |
Drift detection and the state-as-truth gate
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 at 2 a.m.) fails the pipeline. Maester is purpose-built for Entra-as-code testing and ships CA-specific tests; running it on a schedule turns “did anyone change CA?” into a green/red check. The change-management contract in one table:
| Change-management control | Mechanism | What it prevents |
|---|---|---|
| All changes via PR | Git + branch protection | Unreviewed CA changes |
| Report-only by construction | Pipeline forces the state on deploy | Untested enforcement |
| Manual promotion gate | Gated environment + approval | Accidental enforce |
| Scheduled drift test | Maester / Microsoft365DSC on a timer | Portal drift winning silently |
| Backup before deploy | Timestamped export step | No rollback path |
| What-If as a release gate | Simulate per-persona before promote | Promoting a policy that locks out a persona |
Architecture at a glance
Picture the framework as three concentric layers wrapped around an AND-evaluation engine, with no diagram required because the structure is the architecture. At the center is the CA evaluation engine: for each sign-in it gathers every enabled policy whose conditions match the (user, app, device, location, client-app-type, risk) tuple and intersects their grant controls — block beats everything, otherwise all grants must be satisfied. The entire design exists to make what enters that engine predictable.
The innermost layer is the per-persona policy bands. A sign-in’s user resolves to exactly one persona via group membership, which selects exactly one band — CA1xx for admins, CA2xx for internals, and so on. Within the band, the grant policies (MFA / authentication strength / compliant device) say “what you must prove,” the session policies (sign-in frequency, persistent-browser, CAE, token protection) say “how the session is constrained,” and the xx9 global block says “deny anything not explicitly approved.” Because the personas are disjoint, the engine never has to reconcile two personas’ bands for one identity — the AND stays tractable.
The middle layer is the cross-cutting mechanisms that bands reach into rather than duplicate. Authentication strengths are named requirements (…002/…003/…004 plus customs) that any band’s grant policy references, so “phishing-resistant” is defined once and reused. Authentication context (c1–c25) is a set of named sensitive-action targets that step-up policies key on, so a privileged action gets step-up regardless of which app hosts it — wired into PIM activation, Purview labels, and your own apps via the claims challenge. Filters (app custom-security-attribute tags; device deviceFilter rules) are how policies target populations of apps and devices instead of brittle ID lists, so new objects inherit policy by being tagged.
The outermost layer is the global fence: CA001 (block legacy auth) and CA003 (block device-code/transfer) close the protocol-level bypasses, CA002 (block unmodelled identities) ensures exhaustiveness by denying anyone in no persona, and the break-glass exclusion threads through every block and MFA policy as the one guaranteed way back in. Surrounding all of it is the change-management loop: every policy lives in Git, deploys report-only first, is simulated with What-If, promoted only by a gated human decision, and continuously checked for drift. Read from the outside in, the architecture is: fence the bypasses, partition the estate, reuse the named mechanisms, and never enforce anything a machine deployed without a human reading the report-only telemetry first.
Real-world scenario
Helvar Pay is a regulated EU fintech: ~9,000 internal staff, ~1,200 B2B contractors and partner-bank guests, a 40-engineer platform team, and a recurring external-audit finding that would not die — contractors were reaching the Azure management plane from unmanaged laptops. Their CA estate had grown to 43 policies over four years, named in the usual archaeology (Require MFA - all v2, Compliant device - admins - DO NOT DELETE). The specific failure: their “require compliant device for admin access” policy targeted a hand-curated app list, and the Windows Azure Service Management API (well-known app ID 797f4846-ba00-4fd7-ba43-dac1f8f63013) had never been added. So contractors with the Contributor role hit portal.azure.com and the ARM API from BYOD laptops, satisfying only MFA, with no device control — for eighteen months.
The constraint that made it hard: they could not simply require compliant devices for all admin access, because their break-glass accounts and a small set of vendor-support sessions legitimately came from secure admin workstations (SAWs) that were not Intune-enrolled but were tagged with extensionAttribute1 = SAW. A blunt “all admins need a compliant device” policy would have locked out the SAWs and the vendor support path — and, on a bad day, the break-glass accounts.
We collapsed the estate into the persona model over six weeks, every policy landing in report-only first. The contractors were re-slotted from a vague “external users” notion into two clean personas: most became Guests (CA4xx — MFA, no admin-portal access via CA403), and the handful with privileged roles became Guest admins (CA5xx — phishing-resistant MFA via CA501, no home-tenant MFA trust). The admin device requirement became CA102, scoped not by an app list but by a filter for apps (tag CASecurity/highImpact = true), so the ARM API — and every other management-plane app — was covered by being tagged, and no app could ever be forgotten again. The SAW exception became a device filter, not an app or user carve-out:
{
"displayName": "CA102-Admins-Require-Compliant-On-HighImpact",
"conditions": {
"users": { "includeGroups": ["<admins-group-id>"], "excludeGroups": ["<breakglass-group-id>"] },
"applications": {
"includeApplications": ["All"],
"applicationFilter": { "mode": "include", "rule": "CustomSecurityAttribute.CASecurity_highImpact -eq \"True\"" }
},
"devices": {
"deviceFilter": { "mode": "exclude", "rule": "device.extensionAttribute1 -eq \"SAW\"" }
}
},
"grantControls": { "operator": "OR", "builtInControls": ["compliantDevice"] }
}
The compliant-device requirement now applied to every high-impact app automatically; SAWs were exempted by a tag they already carried; break-glass was exempted by the group exclusion; and CA199 caught everything else. Crucially, they watched the Insights and reporting workbook during the report-only phase: it showed exactly which contractor sign-ins would have been blocked, which let them issue compliant devices before enforcing — so enforcement day produced zero surprise tickets instead of a flood.
The numbers at the end: policy count dropped from 43 to 21, the audit finding closed (the auditor’s “show me management-plane access requires a compliant device” was now a one-line filter rule, not an archaeology session), and the next new management app inherited the control with zero policy edits. The estate moved into Git with a nightly Maester drift check; three weeks later it caught an engineer who had toggled CA102 to report-only during a 1 a.m. incident and forgotten to revert — the run went red, and the policy was back to enforced by 9 a.m. The lesson on the wall: “A CA estate you can only understand by simulation is one you don’t control. Partition it, tag instead of list, and let a machine tell you when reality drifts from the repo.”
Advantages and disadvantages
The persona-based, policy-as-code model both costs more up front and pays for itself the first time an auditor asks a question or a rollout goes wrong. Weigh it honestly:
| Advantages (why this model wins at scale) | Disadvantages (why it costs to adopt) |
|---|---|
| For any sign-in you can name exactly which policies apply (disjoint personas) — the AND becomes reasoning, not simulation | Requires up-front partition work and a clean group structure; greenfield is easy, migrating a 40-policy estate is weeks |
Deny-by-default per persona (xx9 blocks) means “forgot to model it” = “denied,” not a silent gap |
More policies than a flat design; the count looks higher even though comprehension is higher |
| New apps/devices/admins inherit the right controls by tag/membership — zero policy edits as the estate grows | Filters and custom security attributes need their own governance and roles (Attribute Definition/Assignment Admin) |
| Self-documenting numbering makes audit and incident response fast (the name states intent) | Discipline is fragile — one MFA test FINAL off-scheme policy erodes the self-documentation |
| Authentication context decouples sensitivity from app, so step-up scales without per-app policies | Requires app changes (claims challenge) to use contexts fully; out-of-the-box bindings (PIM/Purview) are easier |
| Authentication strengths kill phishable methods precisely without locking out the long tail | A phishing-resistant strength locks out anyone without a FIDO2/WHfB method — needs a method rollout first |
| Policy-as-code + report-only + What-If makes every change safe and reversible | Needs CI/CD, a service principal, drift tooling (Maester/M365DSC) — real engineering investment |
| Break-glass exclusion threaded everywhere makes lockout recoverable by design | The exclusion group is itself a target; needs monitoring (membership-change alerting) |
The model is right for any organization past ~200 seats, especially regulated ones and heterogeneous estates (employees + contractors + guests + guest-admins + workloads). It is overkill for a 20-person startup with one persona and three apps. The disadvantages are all front-loaded: the partition, the tooling, and the method rollouts are one-time investments, after which the estate stays maintainable as it grows — precisely the property the flat design lacks.
Hands-on lab
Build a minimal but real persona-based CA slice — an Internals persona with MFA and a global block, plus an admin authentication-strength policy and an authentication context — entirely in report-only, then validate with What-If, then tear it down. This is safe to run in a test tenant; everything lands in report-only so nothing is enforced. Run in Cloud Shell or local PowerShell with the Graph SDK.
Safety note: do this in a non-production or test tenant. Even in report-only, work methodically and confirm each
stateisenabledForReportingButNotEnforcedbefore moving on.
Step 1 — Connect with the right scopes.
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess","Group.ReadWrite.All","Policy.Read.All"
Get-MgContext | Select-Object Account, Scopes
Expected: your account and the three scopes listed.
Step 2 — Create the persona and break-glass groups.
$internals = New-MgGroup -DisplayName "CA-Persona-Internals" -MailEnabled:$false `
-MailNickname "ca-internals" -SecurityEnabled:$true
$admins = New-MgGroup -DisplayName "CA-Persona-Admins" -MailEnabled:$false `
-MailNickname "ca-admins" -SecurityEnabled:$true
$breakglass = New-MgGroup -DisplayName "CA-BreakGlass-Exclude" -MailEnabled:$false `
-MailNickname "ca-breakglass" -SecurityEnabled:$true
"$($internals.Id) | $($admins.Id) | $($breakglass.Id)"
Expected: three GUIDs. Keep them; the policies reference them.
Step 3 — Create the Internals require-MFA policy (report-only).
$ca201 = @{
displayName = "CA201-Internals-Require-MFA"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeGroups = @($internals.Id); excludeGroups = @($breakglass.Id) }
applications = @{ includeApplications = @("All") }
clientAppTypes = @("all")
}
grantControls = @{ operator = "OR"; builtInControls = @("mfa") }
}
$p201 = New-MgIdentityConditionalAccessPolicy -BodyParameter $ca201
$p201 | Select-Object DisplayName, State, Id
Expected: CA201-Internals-Require-MFA, State = enabledForReportingButNotEnforced.
Step 4 — Create the Internals global block (report-only).
$ca299 = @{
displayName = "CA299-Internals-GlobalBlock-Unapproved-Apps"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeGroups = @($internals.Id); excludeGroups = @($breakglass.Id) }
applications = @{ includeApplications = @("All") } # carve approvals via filter/exclusions in real use
clientAppTypes = @("all")
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
$p299 = New-MgIdentityConditionalAccessPolicy -BodyParameter $ca299
$p299 | Select-Object DisplayName, State
Expected: the block policy, report-only. (In production you would exclude an approved-app filter; here we keep it simple and report-only so nothing is blocked.)
Step 5 — Create an authentication context and bind a step-up policy.
# Create authentication context c5
$ctxBody = @{ id = "c5"; displayName = "Privileged admin actions"; description = "Step-up demo"; isAvailable = $true }
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/authenticationContextClassReferences" `
-Body ($ctxBody | ConvertTo-Json)
# Admin step-up on c5 with phishing-resistant strength
$ca103 = @{
displayName = "CA103-Admins-StepUp-On-c5"
state = "enabledForReportingButNotEnforced"
conditions = @{
users = @{ includeGroups = @($admins.Id); excludeGroups = @($breakglass.Id) }
applications = @{ includeAuthenticationContextClassReferences = @("c5") }
}
grantControls = @{ operator = "OR"; authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" } }
}
$p103 = New-MgIdentityConditionalAccessPolicy -BodyParameter $ca103
$p103 | Select-Object DisplayName, State
Expected: the step-up policy created, report-only. Note it targets the context c5, not an app list.
Step 6 — Verify the estate and confirm everything is report-only.
Get-MgIdentityConditionalAccessPolicy -All |
Where-Object DisplayName -like "CA*" |
Select-Object DisplayName, State | Sort-Object DisplayName
Expected: all four CAxxx policies listed, every State = enabledForReportingButNotEnforced. This is the validation that the discipline held — nothing you created is enforcing.
Step 7 — Simulate with What-If (portal) or inspect report-only telemetry. In the Entra admin center → Conditional Access → What If, pick a test internal user, app “All”, and run it — you should see CA201 and CA299 listed as applied (report-only). Pick a break-glass-group member and confirm no policies apply. (The Graph evaluate action automates this for CI.)
Validation checklist. You created a disjoint persona structure (Internals, Admins) with a break-glass exclusion group, authored grant + block + step-up policies all in report-only, bound a step-up policy to an authentication context rather than an app, and confirmed via What-If that break-glass matches zero policies. The lab maps to the framework like this:
| Step | What you did | What it proves |
|---|---|---|
| 2 | Persona + break-glass groups | The partition is group membership; break-glass is a group |
| 3 | Internals MFA (report-only) | Grant layer; report-only-first discipline |
| 4 | Internals global block (report-only) | Deny-by-default floor; block coexists with grant (AND) |
| 5 | Auth context + step-up | Step-up targets an action (c5), not an app |
| 6 | Verify all report-only | The discipline held; nothing enforces |
| 7 | What-If on break-glass | Break-glass matches zero policies — the recovery path is intact |
Teardown (remove everything you created).
foreach ($p in @($p201, $p299, $p103)) {
Remove-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $p.Id
}
# Remove the authentication context
Invoke-MgGraphRequest -Method DELETE `
-Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/authenticationContextClassReferences/c5"
# Remove the groups
foreach ($g in @($internals, $admins, $breakglass)) { Remove-MgGroup -GroupId $g.Id }
Disconnect-MgGraph
Cost note. Conditional Access, authentication strengths, and authentication context require Entra ID P1 (per-user or P1-equivalent licensing) but carry no per-policy or per-operation charge — the lab costs nothing beyond the P1 entitlement you already have. Risk-based conditions would require P2; this lab uses none.
Common mistakes & troubleshooting
The failure modes that bite hardest in real CA estates — first as a scannable table, then the expanded reasoning for the ones that cause the most pain. The “confirm” column is the exact tool or query that tells you the truth.
| # | Symptom | Root cause | Confirm (exact path / query) | Fix |
|---|---|---|---|---|
| 1 | Locked out after a phishing-resistant rollout; admins can’t get in | Policy enforced without a break-glass exclusion (or break-glass had no FIDO2 method) | Sign-in logs → CA result failure for admins; break-glass excluded? |
Sign in as break-glass; add excludeGroups to the policy; re-pilot in report-only |
| 2 | New SaaS app has no device control despite a “compliant device” policy | App never added to the hand-curated app list | Compare app list in the policy vs the actual app inventory | Switch to filter for apps by tag; tag the app highImpact |
| 3 | Device filter “exclude approved devices” doesn’t exempt some devices | Unregistered devices have null properties; positive operator doesn’t match | What-If with the device; check device registration state | Use a positive “must have” property; let global block catch nulls; or negative operator |
| 4 | API rejects a CA policy on create/update | mfa combined with authenticationStrength, or read-only fields sent |
The API error names the conflicting field | Remove mfa when using a strength; strip id/createdDateTime/etc. |
| 5 | Users over-prompted; flood of MFA-fatigue tickets | Two policies’ grant controls intersect into “MFA every time” unintentionally | What-If the user; list all applied policies and their sign-in-frequency | Consolidate sign-in frequency; ensure personas are disjoint (no double-banding) |
| 6 | Legacy SMTP relay broke overnight after a block | CA001 block-legacy-auth enforced before reading report-only |
Sign-in logs → legacy client-app-type sign-ins now failure |
Re-enable; read report-only first; exclude documented service accounts temporarily; migrate to modern auth |
| 7 | A whole population is unexpectedly blocked | CA002 (unmodelled) caught identities a persona group missed |
Report-only would-block on CA002; list the affected users |
Add those identities to the correct persona group; then the block stops catching them |
| 8 | Step-up never triggers for a sensitive app action | App doesn’t request the acrs claims challenge, or context not published |
Decode the token: is acrs present? Is the context isAvailable? |
Implement the claims challenge in the app; publish the context |
| 9 | Guest can’t sign in at all | Guest required a method/strength they don’t have; no home-tenant MFA trust | Sign-in logs → guest CA failure; cross-tenant access settings |
Enable inbound MFA trust, or require only built-in MFA (…002) for guests |
| 10 | A policy change in the portal silently reverted your repo intent | No drift detection; portal change won over Git | Maester/M365DSC drift run; compare deployed vs repo | Run drift on a schedule; treat the portal as read-only; reapply from repo |
| 11 | New admin isn’t getting admin policies | Admins is a dynamic group with a stale/typo rule, resolving wrong | Get-MgGroupMember on the admins group; is the new admin in it? |
Use assigned groups for admins; sync from PIM-eligible role holders |
| 12 | “Require compliant device” blocks everyone including managed devices | Devices aren’t actually reporting compliant (Intune), or trustType mismatch | Device blade → compliance state; What-If with a known-compliant device | Fix Intune compliance/enrollment; verify trustType/isCompliant values |
The expanded reasoning for the entries that cause the most damage:
1. Locked out after a phishing-resistant rollout. The policy enforced phishing-resistant MFA on admins without a break-glass exclusion, or break-glass itself had no FIDO2/WHfB method and so couldn’t satisfy the strength. Confirm: the Sign-in logs blade (filtered to admins) shows CA result failure with the policy name; check whether the break-glass group is in excludeGroups. Fix: sign in with a break-glass account, add the break-glass excludeGroups, return the policy to report-only, and confirm admins with a phishing-resistant method pass before re-enforcing.
3. Device filter doesn’t exempt some devices. The null-property trap — unregistered devices have all properties null, so a positive operator (device.extensionAttribute1 -eq "SAW") never matches them and the exclude doesn’t fire. Confirm: run What-If with the device, or check its registration state in the Devices blade. Fix: express the exclude with a property the device must positively have (and let the global block catch nulls), or use a negative operator (-ne/-notStartsWith) to deliberately target null/unregistered devices.
4. API rejects the policy. Either mfa is listed in builtInControls alongside an authenticationStrength (the MFA built-in is strength …002, so it’s redundant and rejected), or you sent a read-only field (id, createdDateTime, modifiedDateTime, templateId) on a create. Confirm: the Graph error names the conflicting/read-only field. Fix: drop mfa when specifying a strength; strip read-only fields before New-Mg…/POST.
7. A whole population is unexpectedly blocked. The CA002 unmodelled-identities fence is doing its job — it caught identities no persona group covers (a new account, an external identity, a forgotten service principal). It surprises people who didn’t expect that population to exist. Confirm: the CA002 report-only would-block count and the sign-in logs list exactly which identities it caught — that list is your gap report. Fix: add those identities to the correct persona group (removing them from CA002’s scope) so they get the right band’s policies instead.
10. A portal change silently reverted your intent. No drift detection, so a manual portal change (an incident-time toggle, a well-meaning fix) won over the Git repo unnoticed. Confirm: a Maester or Microsoft365DSC drift run comparing deployed state to the repo flags it. Fix: run the drift check nightly, treat the portal as read-only for CA, and reapply from the repo on drift — the green/red check makes “did anyone change CA?” answerable.
Best practices
- Partition first, policies second. Define the eight personas and their groups before writing a single policy. The partition is the architecture; policies are its expression. A clean disjoint partition makes everything downstream tractable.
- Use assigned groups for break-glass-sensitive personas (Admins, Guest admins, Service accounts). A dynamic-rule typo that empties an admin scope fails silently — the strict policy applies to nobody. Assigned membership fails loud.
- Every block and MFA policy carries the break-glass exclusion — no exceptions. Make it a CI lint check so a policy without it cannot merge. This single rule is the difference between a recoverable bad rollout and a tenant lockout.
- Reserve
xx9for the per-persona global block, and make it real. Deny-by-default turns “forgot to model it” into “denied” instead of a silent gap. The block is the framework’s backbone, not an afterthought. - Replace lists with filters. Filter for apps (custom security attribute) and devices (
deviceFilterrule) so new objects inherit policy by tag/property. Enumerated ID lists rot and hit the 3072-char cap. - Prefer authentication strengths over blunt
mfa, and kill SMS/voice with a custom strength even before mandating full passwordless. But never put a population on a strength they cannot satisfy — roll the methods first. - Use authentication context for step-up, not per-app step-up policies. Bind one context to PIM activation, Purview labels, and your apps via the claims challenge. The policy set stays flat as the sensitive-action surface grows.
- Report-only first, always; promote only after clean telemetry. Make the pipeline deploy report-only by construction and require a gated human decision to enforce. The single most effective anti-lockout discipline.
- Manage CA as code with a drift gate. Policies in Git, deployed via a federated (secretless) service principal, checked nightly by Maester/Microsoft365DSC. Treat the portal as read-only; let the drift check catch the 2 a.m. toggle.
- What-If every persona and break-glass before enforcing. One identity per persona must match its band; break-glass must match zero policies. Wire the Graph
evaluateaction in as a release gate. - Close the four structural gaps explicitly:
CA001legacy auth,CA003device-code/transfer, the break-glass exclusion, andCA002unmodelled identities. These are the fence; the persona bands are useless if it has holes. - Govern the supporting objects. Custom security attributes have their own roles (Attribute Definition/Assignment Admin) — keep them least-privilege and separate from CA admin. Access-review the Service-accounts and Developers groups quarterly so exempt populations don’t sprawl.
Security notes
- Break-glass is the crown-jewel control. Two cloud-only accounts, excluded everywhere, offline-stored long passphrases plus a dedicated FIDO2 key in a safe, permanent Global Admin (not PIM-eligible, so activation can’t be the thing that’s blocked), and a high-severity alert on any sign-in and any change to the exclusion group’s membership. An attacker who adds themselves to that group has neutralized your entire CA estate — alert on it as critically as on the sign-in.
- Phishing-resistant for all privileged access. Admins and Guest admins require phishing-resistant MFA (
…004) — FIDO2, Windows Hello, or CBA. Push is push-bombable, SMS is SIM-swappable; only phishing-resistant methods resist adversary-in-the-middle kits. The highest-value strength assignment in the tenant. - Token theft mitigation beyond MFA. Layer Continuous Access Evaluation (near-real-time revocation on risk/location change) and token protection (binding the token to the device’s sign-in session) on critical apps. MFA proves who signed in; CAE and token protection limit what a stolen token can do afterward.
- Workload identities are a distinct attack surface. Service principals and managed identities don’t do interactive MFA — govern them with workload-identity CA (
CA7xx): lock sign-in to named locations and (with P2) block risky workload identities. A compromised SP secret is as dangerous as a compromised admin. See Locking Down Workload Identities. - Guard the consent surface alongside CA. CA controls authentication; it does not stop a user consenting to a malicious OAuth app that then has standing API access. Pair it with consent governance — restrict user consent, require admin consent for risky scopes. See Governing OAuth Consent and Application Permissions.
- Least privilege on the CA tooling itself. The CI service principal needs only
Policy.ReadWrite.ConditionalAccess(plusGroup.Readfor name resolution) — not Global Admin. Use a federated credential so there is no secret to steal. The pipeline that can rewrite your authentication posture is itself a crown-jewel system. - Don’t let report-only become a permanent escape hatch. A policy parked in report-only “temporarily” for months is a control you think you have but don’t. Track report-only age; past its pilot window, a policy is either ready to enforce or should be deleted.
Cost & sizing
Conditional Access has an unusual cost profile: the capability is licensed, but there is no metered, per-operation, or per-policy charge. The bill drivers and what each licenses:
| What you need | License | Per-policy / per-op cost | Notes |
|---|---|---|---|
| Conditional Access (all of this framework) | Entra ID P1 (per user) | None | Personas, grant/session/block, filters, contexts, strengths — all P1 |
| Authentication strengths (built-in + custom) | Entra ID P1 | None | No extra cost beyond P1 |
Authentication context (c1–c25) |
Entra ID P1 | None | No extra cost beyond P1 |
| Risk-based conditions (sign-in/user risk) | Entra ID P2 | None per op | ID Protection risk signals require P2 |
| Workload-identity Conditional Access | Workload Identities Premium (per-SP add-on) | Per-SP, annual | Separate SKU; priced per workload identity governed |
| Custom security attributes (filter for apps) | Included with P1/P2 directory | None | Roles, not licenses, govern who can define/assign |
Sizing is about licensing coverage, not infrastructure: every user you want CA to govern needs a P1 (or a bundle that includes it — Microsoft 365 E3 includes P1, E5 includes P2). The common sizing mistakes:
| Sizing mistake | Consequence | Right move |
|---|---|---|
| Some users unlicensed for P1 | CA does not enforce on unlicensed users — a silent gap exactly where you think you’re covered | License every user CA should govern; audit for P1 coverage gaps |
| Buying P2 for everyone when only some need risk | Over-spend on E5/P2 estate-wide | P2 only for populations needing risk-based CA (often admins); P1 for the rest |
| Forgetting workload-identity SKU | CA7xx workload policies can’t be created |
Budget the per-SP Workload Identities Premium add-on for governed service principals |
| Treating CA as “free” operationally | The engineering cost (CI/CD, drift tooling, method rollouts) is real | Budget the one-time partition + tooling + FIDO2-key rollout as a project |
Rough figures for an Indian enterprise: Entra ID P1 is roughly ₹500–550 per user/month standalone (far cheaper bundled in M365 E3 at ~₹3,000–3,500/user/month, which most enterprises already buy); P2 (or E5) adds a premium for the risk signals. The real cost of this framework is not the licenses you already own — it is the engineering project: partitioning the estate, standing up the CI/CD-with-drift pipeline, and rolling out phishing-resistant methods (FIDO2 keys at ~₹2,000–4,000 per admin, or free via Windows Hello for Business on managed devices). For the 9,000-seat scenario fintech, the framework rode entirely on P1 they already had; the spend was ~6 weeks of two engineers plus FIDO2 keys for ~200 admins — a rounding error against the audit finding it closed.
Interview & exam questions
1. Conditional Access is evaluated as an AND across policies. What does that mean for a sign-in, and why does it make ad-hoc CA hard to reason about? A sign-in must satisfy the grant controls of every enabled policy whose conditions match it; there is no operator across policies (the AND is fixed). So two policies that both target a user but impose different controls intersect into the union of their requirements, and the only reliable way to know the effective result is to enumerate every matching policy and intersect them — which is why simulation (What-If) is necessary and why a disjoint persona partition, which guarantees one band per identity, is the fix.
2. Why must the persona partition be both disjoint and exhaustive, and how is each property enforced? Disjoint (one identity → one persona) keeps the AND tractable — you never reason about an identity being two personas at once; it’s enforced by excluding the assigned personas from the dynamic-group membership rules. Exhaustive (every identity is in some persona) closes the silent gap where an unmodelled identity hits no policy and therefore no block; it’s enforced by the CA002 fence policy that blocks anyone in no persona group.
3. You require phishing-resistant MFA for admins. Why can’t you also list mfa in builtInControls in the same policy, and what’s the GUID for phishing-resistant? The mfa built-in is the authentication strength …002 (multifactor), so listing it alongside an authenticationStrength is redundant and the API rejects it. Specify the strength alone. The phishing-resistant built-in strength GUID is 00000000-0000-0000-0000-000000000004 (the others: …002 multifactor, …003 passwordless).
4. Explain authentication context and how it differs from targeting an app. An authentication context is a named ID (c1–c25) representing a sensitive action; a CA policy targets it via includeAuthenticationContextClassReferences instead of an app list. The ID is emitted in the token’s acrs claim, and resources (PIM activation, Purview labels, your own app via a claims challenge) demand it to trigger step-up. The difference: it decouples “the action is sensitive” from “which app it lives in,” so a new privileged app inherits step-up the moment it requests the context — no per-app step-up policy needed.
5. A device filter uses device.extensionAttribute1 -eq "SAW" to exclude approved devices from a block. Why might an unregistered device still be blocked, and is that correct? Unregistered devices have all properties null, so the positive -eq never matches them, the exclude doesn’t fire, and the block applies. That is usually correct (you want unregistered devices blocked), but it’s a trap if you wrote the filter expecting to catch unregistered devices positively. The rule: positive operators for “must have”; let the global block catch nulls; negative operators (-ne) to deliberately target null/unregistered devices.
6. What is the per-persona “global block,” what number convention marks it, and why is it the framework’s backbone? It’s a block-all-apps policy (carving out an explicit approved-app allow-list, ideally via a filter) for the persona, marked with the reserved xx9 slot (CA199, CA299, …). Because block beats grant and CA is AND-evaluated, it coexists with the grant policies as a deny-by-default floor — anything you forgot to model is denied, not silently allowed. It’s the backbone because it converts gaps into denials.
7. Name the four structural gaps every CA design must close, and the policy for each. Legacy authentication (CA001, block clientAppTypes exchangeActiveSync/other); device-code/authentication-transfer phishing flows (CA003, block via the authenticationFlows condition); break-glass lockout (the exclusion threaded through every block/MFA policy); and the unmodelled persona (CA002, block all users excluding every persona group plus break-glass and registration apps).
8. Why use assigned groups rather than dynamic groups for the Admins persona? A dynamic-membership rule with a typo or a sync issue can resolve to zero members silently, so a strict policy (phishing-resistant MFA) would apply to nobody and you wouldn’t notice until an incident. Assigned membership (fed from PIM-eligible role holders by a controlled job) fails loud — a missing admin is visible. The failure mode of the most security-critical population must not be silent.
9. Walk through how your own application uses authentication context to step up a sensitive operation. The API checks the incoming token’s acrs claim for the context (e.g. c5); if absent, it returns a 401 with a claims challenge (insufficient_claims and a claims parameter demanding acrs: c5); the client redirects the user through interactive auth carrying that parameter; CA sees the context demand, applies the step-up policy, forces the required strength; the new token carries acrs: c5; the operation proceeds. This binds step-up to the action without a per-app policy.
10. What is the mandatory first state for any new or changed CA policy, and how do you decide when to promote it? enabledForReportingButNotEnforced (report-only) — the policy is evaluated and logged but not enforced. Promote to enabled only after the report-only telemetry (the Insights and reporting workbook) shows the policy would behave as intended with no unexpected would-block events, and ideally only via a gated, human-approved pipeline step. This is the single most effective discipline against CA lockouts.
11. How do filters for apps and devices prevent the most common CA rot, and what’s the hard limit on a device-filter rule? They target populations by attribute/property rather than enumerating object IDs, so a newly created app or device inherits the policy by being tagged or by having the property — no policy edit, no list to maintain. The device-filter rule string is capped at 3072 characters, which is precisely why you tag and filter rather than enumerate device GUIDs (a list of GUIDs would blow the cap).
12. How do you detect and prevent CA “drift,” and why does it matter? Manage policies as code in Git and run a scheduled drift check (Maester or Microsoft365DSC) that compares the deployed estate to the repo, failing if they differ — so a manual portal change (an incident-time toggle, a well-meaning fix) is caught and reverted rather than silently winning. It matters because CA is the tenant’s authentication posture; an undetected change can open a gap or cause an outage, and “did anyone change CA?” must be an answerable, automated question.
These map to SC-300 (Identity and Access Administrator) — implement and manage Conditional Access, authentication methods, and identity governance — most directly, with the workload-identity and risk angles touching SC-200/SC-100, and the policy-as-code/Graph automation relevant to SC-300 and architect-level SC-100 design. A compact cert mapping:
| Question theme | Primary cert | Objective area |
|---|---|---|
| CA evaluation model, personas, global block | SC-300 | Plan and implement Conditional Access |
| Authentication strengths, methods | SC-300 | Manage authentication methods |
| Authentication context, step-up, PIM/Purview binding | SC-300 / SC-100 | Privileged access; sensitive-action protection |
| Filters, custom security attributes | SC-300 | Conditional Access targeting |
| Break-glass, gaps (legacy auth, device-code) | SC-300 / SC-100 | Resilient identity; secure access design |
| Workload-identity CA, risk conditions | SC-300 / SC-200 | Protect identities and workloads |
| Policy-as-code, Graph, drift | SC-100 | Design a security posture for identities |
Quick check
- A user signs in and three CA policies match them — two require MFA, one requires a compliant device, none returns block. What must the user satisfy, and which single property of the partition would have let you predict this without simulation?
- You enforce phishing-resistant MFA on admins and immediately lock everyone out, including yourself. Name the one exclusion that should have been on the policy, and the one account type that gets you back in.
- Your “require compliant device” policy targets a hand-curated app list and a brand-new SaaS app has no device control. What mechanism replaces the list so this can’t recur, and what do you tag the app with?
- What is the reserved last digit in the CAxxx scheme for, why does it work despite the MFA grant policies also being in force, and what would land there if you forgot to model it?
- You want step-up to phishing-resistant MFA only when a user performs a sensitive operation in your own app — not on every sign-in. What two things do you create/use, and what token claim carries the signal?
Answers
- The user must satisfy MFA AND a compliant device — CA is AND-evaluated across policies, so all matching grant controls combine (MFA from the two MFA policies, compliant device from the third). The property that lets you predict it without simulation is a disjoint persona partition: if every identity belongs to exactly one persona, you know precisely which band’s policies apply, so you can enumerate them by hand instead of running What-If.
- The policy needed a break-glass
excludeGroupsexclusion. A cloud-only break-glass account (excluded from every policy, with a non-failing method like a safe-stored FIDO2 key) gets you back in. Then add the exclusion, return the policy to report-only, and re-pilot. - Filter for apps (targeting by a custom security attribute) replaces the hand-curated list; tag the app with the attribute the filter keys on, e.g.
CASecurity/highImpact = true. New apps inherit the policy by being tagged — no policy edit. - The reserved last digit
9(CA199,CA299, …) is the per-persona global block — deny-by-default for any app not explicitly approved. It works alongside the grant policies because block beats grant and CA is AND-evaluated: a sign-in must pass the grants and not be blocked. Anything you forgot to model (an unapproved app, an unmodelled flow) lands in the block and is denied, not silently allowed. - Create an authentication context (
c1–c25, e.g.c5) and a CA step-up policy that targets that context (includeAuthenticationContextClassReferences) with a phishing-resistantauthenticationStrength; your app demands it via a claims challenge. Theacrsclaim in the access token carries the context ID, and the resource requires it to allow the sensitive operation.
Glossary
- Conditional Access (CA) — Entra’s policy engine that joins identity, device, location, app, client-app-type, and risk signals into an allow/block/step-up decision on every sign-in; evaluated as a logical AND across all matching policies.
- Persona — a population of identities sharing a security posture (globals, admins, internals, guests, guest-admins, developers, service accounts, workload identities), expressed as group membership; the unit of partition.
- Grant control — what a sign-in must prove to be allowed (MFA, compliant device, authentication strength, terms of use);
blockhere overrides every grant anywhere. - Session control — a constraint on the authenticated session (sign-in frequency, persistent-browser, app-enforced restrictions, Continuous Access Evaluation, token protection).
- Global block (
xx9) — a per-persona deny-by-default policy that blocks all apps except an explicit approved allow-list; converts unmodelled access into a denial. - Authentication strength — a named, version-managed combination of authentication methods used in
grantControls.authenticationStrength; three built-ins (multifactor…002, passwordless…003, phishing-resistant…004) plus custom strengths. - Authentication context (
c1–c25) — a named target representing a sensitive action; a CA policy targets it instead of an app, the ID rides in the token’sacrsclaim, and resources demand it to trigger step-up. acrsclaim — the Authentication Context Class Reference values in an access token; carries which authentication context(s) the session satisfied.- Claims challenge — an
insufficient_claims401 with aclaimsparameter by which an app demands a specificacrs(authentication context), driving the user through step-up. - Filter for apps — targeting apps by a custom security attribute (
applicationFilter) instead of enumerating app object IDs, so new apps inherit policy by being tagged. - Filter for devices — targeting devices by a rule over device properties (
devices.deviceFilter) instead of enumerating device IDs; rule string capped at 3072 characters. trustType— a device property:AzureAD(Entra joined),ServerAD(hybrid joined),Workplace(registered).- Break-glass account — a cloud-only emergency-access account excluded from every CA policy, hardened and monitored, that is the only guaranteed way back in after a bad rollout.
- Report-only (
enabledForReportingButNotEnforced) — a policy state where the policy is evaluated and logged but not enforced; the mandatory first state for every new or changed policy. - What-If — the simulation tool (portal, or the Graph
evaluateaction) that shows which CA policies apply to a given(user, app, device, location, risk)tuple and the combined result. - Continuous Access Evaluation (CAE) — near-real-time token revocation that reacts to risk/location/account changes within minutes instead of waiting for token expiry.
- Token protection — binding an issued token to the device’s sign-in session so a stolen token can’t be replayed from another device.
- Workload-identity Conditional Access — CA for service principals/managed identities (a Workload Identities Premium capability), conditioning on location and (with the add-on) risk; no interactive MFA concept.
- Microsoft365DSC / Maester — tools that assert deployed CA (and other Entra) state matches a repo, enabling drift detection so portal changes fail the pipeline instead of silently winning.
Next steps
You can now impose a maintainable, gap-free Conditional Access architecture on a tenant of any size. Build outward:
- Next: Engineering Break-Glass Emergency Access Accounts in Entra ID — the exclusion that every policy in this framework depends on, done right and monitored.
- Related: Privileged Identity Management and PAM Architecture — wire authentication context to PIM role activation so privileged access is just-in-time and stepped-up.
- Related: Operationalizing Entra ID Protection — add the P2 risk signals that power risk-based CA conditions for the Internals and Admins bands.
- Related: Rolling Out FIDO2 Passwordless Authentication in Entra ID — roll out the phishing-resistant methods before you enforce the
…004strength, so nobody gets locked out. - Related: Locking Down Workload Identities — govern the
CA7xxservice-principal and managed-identity band that human-focused CA can’t reach. - Related: Zero Trust on Microsoft Entra: Conditional Access + PIM — the tactical CA+PIM pairing this framework operationalizes at scale.