Identity Azure

Designing Conditional Access at Scale: A Persona-Based Policy Framework with Authentication Context and Filters

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:

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 (c1c25) 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 (c1c25) 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
CA001CA099 Globals (all personas) CA001 block legacy auth; CA002 block unmodelled identities; CA003 block device-code flow; CA004 block from unsupported platforms; CA005 require ToU
CA100CA199 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
CA200CA299 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
CA300CA399 Developers CA301 require MFA + compliant; CA302 step-up on c6 (prod/management actions); CA303 sign-in frequency on Azure mgmt; CA399 global block
CA400CA499 Guests CA401 guest MFA strength; CA402 guest sign-in frequency; CA403 block guest access to admin portals; CA499 global block
CA500CA599 Guest admins CA501 phishing-resistant MFA (no home-tenant trust); CA502 step-up on c5; CA599 global block
CA600CA699 Service accounts CA601 block except named devices; CA602 block from outside trusted locations; CA699 global block
CA700CA799 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 c1c25 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
extensionAttribute115 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 (c1c25) 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 state is enabledForReportingButNotEnforced before 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 AccessWhat 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

Security notes

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 (c1c25) 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 (c1c25) 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

  1. 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?
  2. 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.
  3. 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?
  4. 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?
  5. 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

  1. 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.
  2. The policy needed a break-glass excludeGroups exclusion. 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.
  3. 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.
  4. 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.
  5. Create an authentication context (c1c25, e.g. c5) and a CA step-up policy that targets that context (includeAuthenticationContextClassReferences) with a phishing-resistant authenticationStrength; your app demands it via a claims challenge. The acrs claim in the access token carries the context ID, and the resource requires it to allow the sensitive operation.

Glossary

Next steps

You can now impose a maintainable, gap-free Conditional Access architecture on a tenant of any size. Build outward:

Entra IDConditional AccessZero TrustAuthentication ContextAuthentication StrengthsPolicy as CodeMicrosoft GraphWorkload Identities
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading