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 policies named like “MFA test 2 FINAL”, overlapping scopes nobody can reason about, and a hand-curated app list that silently stopped covering new workloads. CA is the load-bearing wall of a Zero Trust posture, and ad-hoc CA rots fast because every change is additive and nobody ever deletes. This article lays out the framework I deploy instead: a persona-based design with a strict numbering scheme, authentication strengths and authentication context for step-up, and filters that replace brittle app and device lists. It assumes Conditional Access Administrator (or Security Administrator) plus Entra ID P1; authentication context and strengths add no license beyond P1.

1. Why ad-hoc CA rots, and what “good” looks like

CA policies are evaluated as a logical AND across all matching policies: a sign-in must satisfy every policy that targets it. That makes the system unforgiving of overlap. Two policies that both target “All users” but disagree on grant controls do not merge cleanly in anyone’s head; the only way to know the effective result is to simulate it.

The fix is not “fewer policies.” It is a deterministic partition of the identity estate so that for any (user, app, device, location) tuple you can name exactly which policies apply and why. Three rules get you there:

That last point is the difference between a framework and a pile of allow-rules. The value is in applying the structure consistently, not in any single policy.

2. Define the personas and the numbering scheme

Start by partitioning every account into a persona. The five that cover almost every tenant:

Persona Who Backstop posture
Admins Privileged-role holders, PIM-eligible Phishing-resistant MFA + compliant/SAW device, always
Internals Standard employees MFA + compliant or hybrid-joined device
Guests B2B collaboration users MFA (trust home-tenant MFA where agreed), no device control
Workloads Service principals, managed identities Location + risk via workload-identity CA
Service accounts Non-human directory accounts that still sign in interactively MFA-exempt on named devices only, otherwise blocked

Assign a numeric band per persona and a slot per policy function within it. I use:

Band Persona Example function slots
CA001-CA099 Global / all personas CA001 block legacy auth, CA002 block unsupported device platforms
CA100-CA199 Admins CA101 require phishing-resistant MFA, CA102 require compliant device, CA199 global block
CA200-CA299 Internals CA201 require MFA, CA202 require compliant/hybrid device, CA299 global block
CA400-CA499 Guests CA401 guest MFA strength, CA499 global block
CA500-CA599 Workloads CA501 workload-identity location lockdown
CA600-CA699 Service accounts CA601 service-account device filter, CA699 global block

The xx9 slot in each band is reserved for that persona’s global block all apps except an explicit allow-list — the deny-by-default backstop. Anything you forget to model lands there. Name policies CAxxx-<persona>-<function>, e.g. CA101-Admins-Require-PhishResistant-MFA: the number sorts them; the suffix tells a reviewer what it does without opening it.

Personas map to groups. Use assigned groups for break-glass-sensitive personas (Admins) so a dynamic-rule misconfiguration cannot silently empty the scope; dynamic groups are fine for Internals.

3. Build the policy matrix: grant, session, and the global block

Think in three layers per persona: grant controls (what you must prove to get in), session controls (constraints once in), and the global block (the floor). Here is the Internals require-MFA policy authored against Microsoft Graph v1.0. Note the break-glass exclusion is non-negotiable on every blocking or MFA policy.

Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess","Policy.Read.All"

$params = @{
  displayName = "CA201-Internals-Require-MFA"
  state       = "enabledForReportingButNotEnforced"  # report-only first
  conditions  = @{
    users = @{
      includeGroups = @("<internals-group-id>")
      excludeGroups = @("<breakglass-group-id>")
    }
    applications = @{ includeApplications = @("All") }
    clientAppTypes = @("all")
  }
  grantControls = @{
    operator        = "OR"
    builtInControls = @("mfa")
  }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $params

Session controls live under sessionControls. The two I reach for most: sign-in frequency for high-value apps, and persistent-browser disable for unmanaged devices.

sessionControls = @{
  signInFrequency = @{
    isEnabled = $true
    type      = "hours"
    value     = 4
    frequencyInterval = "timeBased"
  }
  persistentBrowser = @{
    isEnabled = $true
    mode      = "never"
  }
}

The global block (CA299) is the backstop. It blocks all apps for the persona, then you carve exceptions by excluding an explicit allow-group of apps you have consciously approved. Because CA is AND-evaluated, this block coexists with the MFA policy: a sign-in must pass MFA and not be blocked.

$block = @{
  displayName = "CA299-Internals-GlobalBlock-Unapproved-Apps"
  state       = "enabledForReportingButNotEnforced"
  conditions  = @{
    users        = @{ includeGroups = @("<internals-group-id>"); excludeGroups = @("<breakglass-group-id>") }
    applications = @{ includeApplications = @("All"); excludeApplications = @("<approved-app-ids>") }
    clientAppTypes = @("all")
  }
  grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $block

4. Authentication strengths and authentication context for step-up

Authentication strength replaces the blunt mfa grant control with a named, version-managed combination of methods. There are three built-ins, referenced by stable GUID:

Built-in strength GUID
Multifactor authentication 00000000-0000-0000-0000-000000000002
Passwordless MFA 00000000-0000-0000-0000-000000000003
Phishing-resistant MFA 00000000-0000-0000-0000-000000000004

For the Admins persona, require phishing-resistant MFA. Critically, you cannot combine mfa in builtInControls with an authenticationStrength in the same policy — the MFA built-in is the strength ...002, so it is redundant and rejected.

$adminCa = @{
  displayName = "CA101-Admins-Require-PhishResistant-MFA"
  state       = "enabledForReportingButNotEnforced"
  conditions  = @{
    users        = @{ includeGroups = @("<admins-group-id>"); excludeGroups = @("<breakglass-group-id>") }
    applications = @{ includeApplications = @("All") }
    clientAppTypes = @("all")
  }
  grantControls = @{
    operator             = "OR"
    authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" }
  }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $adminCa

List built-ins (and any custom strengths) from the beta endpoint:

az rest --method GET \
  --uri "https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies?\$filter=policyType eq 'builtIn'"

Authentication context is the other half of step-up. Instead of forcing phishing-resistant MFA on every admin sign-in, you bind it to sensitive actions — a privileged portal blade, a labeled SharePoint site, a sensitive operation in your own app. An authentication context is just an ID c1-c25 with a friendly name; the ID is emitted in the acrs claim of the access token, and downstream resources demand it to trigger step-up. Create one against v1.0:

az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/authenticationContextClassReferences" \
  --headers "Content-Type=application/json" \
  --body '{
    "id": "c5",
    "displayName": "Privileged admin actions",
    "description": "Step-up to phishing-resistant MFA for sensitive operations",
    "isAvailable": true
  }'

Then write a CA policy whose target is the context, not an app list. The key is includeAuthenticationContextClassReferences:

$ctxCa = @{
  displayName = "CA103-Admins-StepUp-On-c5"
  state       = "enabledForReportingButNotEnforced"
  conditions  = @{
    users        = @{ includeGroups = @("<admins-group-id>"); excludeGroups = @("<breakglass-group-id>") }
    applications = @{ includeAuthenticationContextClassReferences = @("c5") }
  }
  grantControls = @{
    operator               = "OR"
    authenticationStrength = @{ id = "00000000-0000-0000-0000-000000000004" }
  }
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $ctxCa

Now c5 can be wired to PIM role activation, Purview sensitivity labels, or your own apps via the claims challenge — one context, reused everywhere, instead of a step-up policy per app.

Why this matters: authentication context decouples “the action is sensitive” from “which app it lives in.” A new privileged app inherits step-up the moment it requests c5. No policy edit.

5. Filters for devices and apps to replace brittle lists

Hand-maintained app lists are the most common rot vector. Two filter mechanisms kill them.

Filter for apps lets you target apps by a custom security attribute instead of enumerating object IDs. Tag apps once (for example CASecurity/highImpact = true) and write the policy against the tag — new apps inherit the policy by being tagged, with no policy change.

Filter for devices is the bigger win. The rule lives at conditions.devices.deviceFilter with a mode (include/exclude) and a rule string using dynamic-membership syntax. Operators are -eq -ne -in -notIn -contains -startsWith -notStartsWith -endsWith -notEndsWith -and -or. Supported properties include trustType (AzureAD = Entra joined, ServerAD = hybrid joined, Workplace = registered), deviceOwnership, isCompliant, enrollmentProfileName, and extensionAttribute1-15.

For the Service accounts persona, exempt MFA only on named secure devices and block everywhere else. Tag the approved devices with extensionAttribute2 = SvcAcctKiosk, then exclude that filter from the block:

{
  "displayName": "CA601-SvcAccounts-Block-Except-NamedDevices",
  "state": "enabledForReportingButNotEnforced",
  "conditions": {
    "users": {
      "includeGroups": ["<svc-accounts-group-id>"],
      "excludeGroups": ["<breakglass-group-id>"]
    },
    "applications": { "includeApplications": ["All"] },
    "clientAppTypes": ["all"],
    "devices": {
      "deviceFilter": {
        "mode": "exclude",
        "rule": "device.extensionAttribute2 -eq \"SvcAcctKiosk\""
      }
    }
  },
  "grantControls": { "operator": "OR", "builtInControls": ["block"] }
}

A critical gotcha lives in the evaluation table: for unregistered devices, all properties are null, so a positive operator (-eq) never matches them and the filter does not apply. Express device exclusions with a property the device must positively have and let the global block catch the null/unregistered case — or use a negative operator when you specifically intend to catch unregistered devices. Note too that extensionAttribute1-15 populate only for Intune-managed, compliant, or hybrid-joined devices, and the rule string caps at 3072 characters — exactly why you tag rather than enumerate device IDs.

6. Manage CA as code with Graph and CI/CD

Click-ops does not scale across personas. Treat policies as JSON in Git, deploy through a pipeline, and keep a restorable backup. Export the current estate:

az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
  --query "value" > ca-policies-backup.json

A minimal backup/restore loop in a pipeline. Restore creates from the stored definitions (strip read-only fields like id, createdDateTime, modifiedDateTime first):

# Backup
$pol = Get-MgIdentityConditionalAccessPolicy -All
$pol | ConvertTo-Json -Depth 12 | Out-File "ca-backup-$(Get-Date -f yyyyMMdd).json"

# Restore one policy from a sanitized definition
$def = Get-Content "./policies/CA101.json" -Raw | ConvertFrom-Json
New-MgIdentityConditionalAccessPolicy -BodyParameter $def

For a real GitOps flow, tools like Microsoft365DSC or the Maester test framework assert the deployed state matches the repo on every run, so drift (someone toggling a policy in the portal at 2 a.m.) fails the pipeline. The non-negotiable rule: new and changed policies deploy in enabledForReportingButNotEnforced first, and a human promotes to enabled only after the report-only telemetry is clean.

7. Closing the common gaps

Four gaps that defeat most CA designs:

Enterprise scenario

A platform team running a regulated EU fintech had ~40 CA policies and a recurring audit finding: contractors were reaching the Azure management plane from unmanaged laptops. Their existing “require compliant device” policy targeted an app list that someone had to update by hand, and Windows Azure Service Management API had never been added. The constraint: they could not simply require compliant devices for all admin access, because their break-glass and a small set of vendor-support sessions legitimately came from secure admin workstations (SAWs) that were not Intune-enrolled but were tagged.

We collapsed it into the persona model. Admins got CA102 requiring a compliant device for the ARM/Azure Management resource, scoped by filter for apps (tag highImpact=true) so no app could ever be forgotten again. The SAW exception became a device filter rather than an app or user carve-out:

{
  "applications": {
    "includeApplications": ["797f4846-ba00-4fd7-ba43-dac1f8f63013"]
  },
  "devices": {
    "deviceFilter": {
      "mode": "exclude",
      "rule": "device.extensionAttribute1 -eq \"SAW\""
    }
  }
}

(797f4846-... is the well-known app ID for Windows Azure Service Management API.) The compliant-device requirement now applied to every high-impact app automatically, SAWs were exempted by a tag they already carried, and the global block CA199 caught everything else. The audit finding closed, and the next new management app inherited the control with zero policy edits. Total policy count dropped from 40 to 19.

Verify

Validate before you enforce, every time.

  1. Report-only telemetry. Leave new policies in report-only for at least one business cycle. In Entra ID > Conditional Access > Insights and reporting, the workbook shows, per policy, how many sign-ins would have been blocked or challenged. A spike of “would block” on a global-block policy means your allow-list is incomplete — fix it before enforcing.
  2. What-If. Use the What-If tool (or the Graph evaluate action) to simulate a specific (user, app, device, location) tuple and confirm exactly which CAxxx policies apply and what the combined result is. Run it for one identity per persona, plus a break-glass account (which must show no policies applied).
  3. Sign-in logs. Query the logs to confirm enforcement matches intent. The KQL below (Log Analytics / Sentinel, SigninLogs) surfaces every sign-in a given policy enforced a control on:
SigninLogs
| where TimeGenerated > ago(7d)
| mv-expand policy = ConditionalAccessPolicies
| where tostring(policy.displayName) startswith "CA101"
| extend result = tostring(policy.result)
| summarize count() by result, tostring(policy.displayName)
| order by count_ desc

A healthy enforced policy shows success and failure rows; a row of reportOnlySuccess means it is still in report-only — promote it.

Checklist

Entra IDConditional AccessZero TrustAuthentication ContextPolicy as Code

Comments

Keep Reading