//sbd.org.uk
Back to blog
Conditional Access Policies That Actually Work: Lessons from 20 Deployments
·13 min read

Conditional Access Policies That Actually Work: Lessons from 20 Deployments

The patterns that survive contact with production, a baseline CA policy set built from common mistakes, circular dependencies, and hard-won lessons across multiple Microsoft 365 deployments.

microsoft-365conditional-accessentra-idsecurity

Conditional Access is the most powerful and most misconfigured feature in Entra ID. Every Microsoft 365 tenant I've assessed has had at least one policy that was either doing nothing, actively blocking legitimate users, or creating a false sense of security by protecting the wrong things in the wrong order.

The documentation is fine. Microsoft's tutorials walk you through creating individual policies, one at a time, with screenshots. What they don't tell you is how those policies interact when you have fifteen of them, what happens when you combine device compliance with MFA registration requirements, or why your break-glass accounts stopped working at 2am on a Sunday.

This post is the baseline policy set I deploy to every new tenant and retrofit into existing ones. It's built from the mistakes I've seen and made, across many deployments. Not every policy will apply to every organisation, but the architecture and the ordering will save you from the problems that catch everyone else.

Every policy in this post is defined as JSON, the native format for the Microsoft Graph Conditional Access API. These aren't pseudocode or portal screenshots; they're the request bodies you pass to New-MgIdentityConditionalAccessPolicy (or POST to /identity/conditionalAccess/policies directly). You can deploy them from PowerShell, import them into Microsoft 365 DSC, or store them in a Git repository and deploy via a CI/CD pipeline. If you're already running a Configuration as Code practice for your M365 tenant, or want to start, the Configuration as Code for Endpoint Management post covers the broader approach. CA policies are one of the highest-value targets for that pattern: they're critical, they drift, and portal-based management gives you no change history.

Conditional Access Evaluation PipelineASSIGNMENTS (WHO + WHAT + WHERE) → CONTROLS (REQUIRE + ENFORCE) → DECISION (ALLOW / BLOCK / LIMIT)Sign-inrequestIdentityverificationPolicyevaluationGrantcontrolsSessioncontrolsAccessdecisionASSIGNMENTSCONTROLS
Every sign-in is evaluated against all enabled policies. Assignments determine if the policy applies; controls determine what it requires.

How Conditional Access Actually Works

Before building policies, you need to understand how Entra ID evaluates them because the mental model most admins have is wrong.

Conditional Access is not a firewall rule set evaluated top-to-bottom. There's no priority ordering. Every enabled policy is evaluated against every sign-in, simultaneously. If a sign-in matches the assignments of multiple policies, all matching grant controls must be satisfied. If any matching policy blocks access, the block wins regardless of what other policies allow.

This means:

  • Block always wins. A single matching Block policy overrides any number of Allow policies with grant controls. This is by design, but it's the number one cause of "I locked myself out" incidents.
  • Grant controls stack. If Policy A requires MFA and Policy B requires a compliant device, and both match the same sign-in, the user must satisfy both MFA and compliant device. This is the "require all" default behaviour.
  • Assignments are AND logic, controls are AND logic. A policy applies when the user AND the app AND the condition all match. When it applies, the user must satisfy control A AND control B (unless you explicitly set "require one of the selected controls").
  • Report-only doesn't enforce. Policies in report-only mode are evaluated but not enforced. They log what would have happened. Use this religiously before enabling anything.

The practical consequence: you must think about your policies as a set, not as individual rules. A policy that looks perfectly reasonable in isolation can create deadlocks, coverage gaps, or admin lockouts when combined with others.


The Five Mistakes I See Everywhere

1. No Break-Glass Exclusion (or a Broken One)

Every CA policy must exclude at least two break-glass accounts. This is table-stakes guidance that everyone agrees with and half of organisations get wrong.

The common failures:

  • Break-glass accounts exist but aren't excluded from all policies. Someone creates a new policy six months later and forgets to add the exclusion. The next time all other admin access fails, the break-glass accounts are locked out too.
  • Break-glass accounts use SMS MFA. If your CA policies require MFA, and your break-glass accounts have MFA enabled but rely on a phone number that's tied to a person who left the company, you have a break-glass account that can't break glass.
  • Break-glass accounts aren't monitored. An unmonitored break-glass account is an unaudited privileged identity sitting in your tenant with no MFA requirement. If you're excluding it from all CA policies, you'd better be alerting on every sign-in it makes.

The fix is structural, not procedural. Use a dedicated security group, I call it CA-Exclude-BreakGlass and exclude that group from every policy. Then monitor it:

# Create the break-glass exclusion group
$bgGroup = New-MgGroup -DisplayName "CA-Exclude-BreakGlass" `
    -MailEnabled:$false -SecurityEnabled:$true -MailNickname "ca-exclude-breakglass" `
    -Description "Break-glass accounts excluded from all CA policies. Monitored via alert rule."
 
# Add break-glass accounts
$bg1 = Get-MgUser -Filter "userPrincipalName eq '[email protected]'"
$bg2 = Get-MgUser -Filter "userPrincipalName eq '[email protected]'"
 
New-MgGroupMember -GroupId $bgGroup.Id -DirectoryObjectId $bg1.Id
New-MgGroupMember -GroupId $bgGroup.Id -DirectoryObjectId $bg2.Id

For monitoring, create a Log Analytics alert rule that fires on any sign-in from these accounts:

SigninLogs
| where UserPrincipalName in ("[email protected]", "[email protected]")
| where ResultType == 0
| project TimeGenerated, UserPrincipalName, AppDisplayName, IPAddress, Location

Break-glass account configuration: Use FIDO2 security keys, not passwords with SMS MFA. Store one key in a physical safe at your office, the other with a trusted senior leader. I often hear that this is not practical, but that's the point - it's not supposed to be practical, it's a 'save my butt' account! The accounts should be cloud-only (no on-prem sync), use an *.onmicrosoft.com UPN (so they work even if your custom domain has issues), and have the Global Administrator role assigned permanently (not via PIM). Document the procedure for using them, test it quarterly, and treat a failed test as a P1 incident.

2. Blocking Legacy Authentication Too Late

Legacy auth; POP, IMAP, SMTP AUTH, ActiveSync with basic auth doesn't support MFA. Any policy that requires MFA is silently bypassed by legacy auth clients. If you don't block legacy auth explicitly, your MFA requirement has a hole in it wide enough to drive a credential-stuffing attack through.

Microsoft has been deprecating basic auth for years, but I still find it enabled in many tenants I assess. The CA policy is straightforward:

{
  "displayName": "BASELINE - Block legacy authentication",
  "state": "enabled",
  "conditions": {
    "users": {
      "includeUsers": ["All"],
      "excludeGroups": ["<CA-Exclude-BreakGlass-GroupId>"]
    },
    "applications": {
      "includeApplications": ["All"]
    },
    "clientAppTypes": [
      "exchangeActiveSync",
      "other"
    ]
  },
  "grantControls": {
    "operator": "OR",
    "builtInControls": ["block"]
  }
}

Deploy this in report-only first. You'll find printers that authenticate via SMTP, service accounts using IMAP to process mailboxes, and occasionally an executive with an ancient Outlook profile. Fix those first, then enforce.

3. MFA for "All Users" That Doesn't Mean All Users

The "Require MFA for all users" policy sounds comprehensive until you look at the exclusions list six months after deployment. It typically includes:

  • Service accounts (because someone couldn't figure out managed identities)
  • A specific department (because they complained)
  • Guest users (because "they have their own MFA")
  • The entire IT team (because "we're testing something")

Each exclusion is a gap. Service accounts without MFA are the most common initial access vector in Entra ID compromises. Guest users from federated organisations may have MFA in their home tenant, or they may not, and you're trusting their security posture without verifying it.

The correct approach: MFA for genuinely all users, with the only exclusion being the break-glass group. Service accounts should use workload identities (managed identities or certificate-based app registrations) that don't go through CA at all. These use app-only authentication (client credentials flow) and bypass CA policies entirely, which is the correct pattern. If a service account authenticates as a user, it needs MFA, or it needs to be re-architected so it doesn't.

4. The Device Compliance Circular Dependency

This one catches everyone at least once. The scenario:

  1. You create a CA policy requiring a compliant device for all cloud apps.
  2. A user gets a new device and tries to enrol it in Intune.
  3. Intune enrolment requires authenticating to Entra ID.
  4. Entra ID blocks the authentication because the device isn't compliant yet.
  5. The device can never become compliant because it can never enrol.
THE PROBLEMCA PolicyRequires compliant deviceAuthBlocked by CA policyIntuneNeeds auth to check inTHE FIXEnrolment appsExcluded from compliance CAIntune + AuthenticatorAllowed to auth and enrolDevice compliantAll other apps now gatedLinear flow replaces circular dependency
Requiring device compliance without excluding enrolment apps creates a deadlock. The fix: exclude Intune and Authenticator from the compliance requirement.

The fix is to exclude the enrolment apps from the compliance requirement. Specifically, exclude Microsoft Intune Enrolment (app ID: d4ebce55-015a-49b5-a083-c84d1797ae8c), Microsoft Intune (app ID: 0000000a-0000-0000-c000-000000000000), and Microsoft Intune Company Portal (app ID: 9ba1a5c7-f17a-4de9-a1f1-6178c8d51223) from any CA policy that requires device compliance. The Company Portal is how users on iOS and Android actually initiate enrolment; miss it and mobile enrolment silently fails. You may also need to exclude Microsoft Authenticator (if you're using it for MFA registration during enrolment).

{
  "displayName": "BASELINE - Require compliant device (all cloud apps)",
  "state": "enabledForReportingButNotEnforced",
  "conditions": {
    "users": {
      "includeUsers": ["All"],
      "excludeGroups": ["<CA-Exclude-BreakGlass-GroupId>"]
    },
    "applications": {
      "includeApplications": ["All"],
      "excludeApplications": [
        "d4ebce55-015a-49b5-a083-c84d1797ae8c",
        "0000000a-0000-0000-c000-000000000000",
        "9ba1a5c7-f17a-4de9-a1f1-6178c8d51223"
      ]
    },
    "platforms": {
      "includePlatforms": ["iOS", "android"]
    }
  },
  "grantControls": {
    "operator": "OR",
    "builtInControls": ["compliantDevice"]
  }
}

Start with mobile. Requiring device compliance across all platforms on day one is ambitious. Start with iOS and Android (where Intune enrolment is mature and users expect managed devices), then extend to Windows once your Autopilot and compliance policy baseline is solid. Doing everything at once is how you end up with fifty exclusions and a policy that means nothing.

5. No Named Locations (or Wrong Ones)

Named locations underpin several important policies: blocking sign-ins from specific countries, reducing friction for users on corporate networks, requiring stricter controls for external access. But the named locations themselves are often misconfigured:

  • Using IP ranges that are too broad. A /16 that was "our corporate range" five years ago now includes subnets that have been reassigned. Users in a co-working space on a different floor are treated as "corporate."
  • Not marking locations as trusted. A named location that isn't marked as trusted is just a label: it doesn't affect MFA behaviour or risk scoring.
  • Relying solely on IP-based locations. With remote work, IP-based trusted locations are less meaningful. Consider GPS-based named locations or compliant network checks via Global Secure Access if you need location-aware policies that work for a distributed workforce.

The Baseline Policy Set

Here's the complete set I deploy to every tenant, in the order they should be created and enabled. Each policy is named with a prefix that indicates its layer.

Baseline CA Policy ArchitectureFoundation1. Block legacy authentication2. Require MFA for all users3. Block high-risk sign-ins4. Block high-risk usersIdentity Protection1. Require MFA registration from trusted network2. Require password change for medium-risk users3. Block sign-ins from restricted countries4. Require compliant device (mobile)Data Protection1. Require app protection policy (mobile)2. Block download from unmanaged devices3. Require ToU for guest access4. Sign-in frequency for sensitive appsAdmin Protection1. Require phishing-resistant MFA for admins2. Require compliant device for admin portals3. Block admin access from non-trusted locations4. Require re-auth every 4h for privileged rolesBREAK-GLASS ACCOUNTS EXCLUDED FROM ALL POLICIES — MONITORED VIA ALERT RULE
Policies are layered from broad foundation controls to targeted admin protections. Every layer excludes break-glass accounts.

Foundation Layer

These four policies are non-negotiable. They go in first, and they protect the tenant from the most common attack vectors.

#Policy NameAssignmentsControls
1BASELINE - Block legacy authenticationAll users, all apps, legacy client typesBlock
2BASELINE - Require MFA for all usersAll users, all apps, modern auth onlyRequire MFA
3BASELINE - Block high-risk sign-insAll users, all apps, sign-in risk: highBlock
4BASELINE - Block high-risk usersAll users, all apps, user risk: highBlock

Policies 3 and 4 require Entra ID P2 (or the equivalent via Microsoft 365 E5). If you don't have P2, replace them with location-based blocking and stronger session controls, but budget for P2. Risk-based policies are the single highest-value CA feature, and they can't be replicated manually.

Identity Protection Layer

These policies add context-aware controls that reduce friction for low-risk scenarios while tightening controls for higher-risk ones.

#Policy NameAssignmentsControls
5IDENTITY - Require MFA registration from trusted networkAll users, user action: register security info, exclude trusted locationsBlock (forces registration on-network)
6IDENTITY - Require password change for medium-risk usersAll users, user risk: mediumRequire password change + MFA
7IDENTITY - Block sign-ins from restricted countriesAll users, all apps, locations: blocked country listBlock
8IDENTITY - Require compliant device (mobile)All users, all apps (excl. enrolment), iOS + AndroidRequire compliant device

Policy 5 deserves explanation. By blocking MFA registration from non-trusted locations, you prevent an attacker who has stolen a password from registering their own MFA method. The user must be on the corporate network (or VPN) to set up Authenticator. This is one of the most effective anti-phishing controls you can deploy and it's routinely overlooked.

Data Protection Layer

These policies protect data at the application and session level: controlling what happens after authentication, not just whether authentication succeeds.

#Policy NameAssignmentsControls
9DATA - Require app protection policy (mobile)All users, Office 365 apps, iOS + AndroidRequire app protection policy
10DATA - Block download from unmanaged devicesAll users, Office 365 apps, filter: unmanaged devicesSession: use app-enforced restrictions
11DATA - Require ToU for guest accessGuest users, all appsRequire terms of use
12DATA - Sign-in frequency for sensitive appsAll users, sensitive app listSession: sign-in frequency 4 hours

Policy 10 uses session controls rather than grant controls. It doesn't block access; it restricts what users can do. On an unmanaged device, they can view documents in the browser but can't download, print, or sync them. This is the right balance for BYOD scenarios where blocking access entirely would drive users to shadow IT. At some point I'll write a post on BYOD and will discuss the best methods to use to both enhance the user experience but also maintain data control. Session Controls are great when you don't have more robust controls but they do limit the user experience somewhat.

Admin Protection Layer

Privileged identities get their own policies because the risk profile is different. An attacker who compromises a Global Admin doesn't need to exfiltrate data. They own the tenant.

#Policy NameAssignmentsControls
13ADMIN - Require phishing-resistant MFA for adminsDirectory roles: GA, PA, SA, EA, HA, CARequire authentication strength: phishing-resistant
14ADMIN - Require compliant device for admin portalsDirectory roles (as above), Microsoft Admin PortalsRequire compliant device
15ADMIN - Block admin access from untrusted locationsDirectory roles (as above), all apps, exclude trusted locationsBlock
16ADMIN - Re-auth every 4h for privileged rolesDirectory roles (as above), all appsSession: sign-in frequency 4 hours, persistent browser: disabled

Authentication strength vs. regular MFA. Policy 13 uses authentication strength (a CA feature) rather than the standard "require MFA" control. Authentication strength lets you specify which MFA methods are acceptable. For admins, you want phishing-resistant methods only: FIDO2 security keys or Windows Hello for Business. Push notifications and SMS are not phishing-resistant, and admin accounts are exactly the accounts that get phished.


Deploying the Baseline via Graph API

Creating sixteen policies by hand through the portal is tedious and error-prone. Use Graph API to deploy them consistently, and export them as JSON for version control.

Export Existing Policies

Before deploying anything, export the current state. This is your rollback snapshot.

# Connect with CA policy permissions
Connect-MgGraph -Scopes "Policy.Read.All","Policy.ReadWrite.ConditionalAccess","Application.Read.All"
 
# Export all CA policies to JSON
$policies = Get-MgIdentityConditionalAccessPolicy -All
$policies | ConvertTo-Json -Depth 10 | Out-File "ca-policies-backup-$(Get-Date -Format yyyyMMdd).json"
 
Write-Host "Exported $($policies.Count) policies"

Deploy a Policy from JSON

Each policy in the baseline set can be deployed from a JSON definition. Here's the pattern:

# Read policy definition from JSON file
$policyJson = Get-Content "policies/baseline-block-legacy-auth.json" -Raw
$policyDef = $policyJson | ConvertFrom-Json
 
# Create the policy in report-only mode first (regardless of the JSON state)
$params = @{
    DisplayName   = $policyDef.displayName
    State         = "enabledForReportingButNotEnforced"
    Conditions    = $policyDef.conditions
    GrantControls = $policyDef.grantControls
}
 
if ($policyDef.sessionControls) {
    $params.SessionControls = $policyDef.sessionControls
}
 
$newPolicy = New-MgIdentityConditionalAccessPolicy -BodyParameter $params
Write-Host "Created policy: $($newPolicy.DisplayName) (ID: $($newPolicy.Id)) in report-only mode"

Always deploy in report-only first. Even if you've tested the policy in a lab tenant, deploy it in report-only mode in production and review the sign-in logs for at least a week before enforcing. This point is important: managers will tell you to go faster rather than waiting a week to see the results. Waiting a week prevents you from creating an incident that could have wide-reaching consequences; stand your ground and stay firm to the week even if it looks good after 4 days (I speak from experience here).

The sign-in logs will show you every sign-in that would have been affected, including service accounts and automated processes you didn't know about.

Bulk Deploy the Full Baseline

# Deploy all baseline policies from a directory of JSON files
$policyFiles = Get-ChildItem "policies/*.json"
 
foreach ($file in $policyFiles) {
    $policyDef = Get-Content $file.FullName -Raw | ConvertFrom-Json
 
    # Check if policy already exists (by display name)
    $existing = Get-MgIdentityConditionalAccessPolicy -Filter "displayName eq '$($policyDef.displayName)'"
 
    if ($existing) {
        Write-Host "SKIP: '$($policyDef.displayName)' already exists (ID: $($existing.Id))"
        continue
    }
 
    $params = @{
        DisplayName   = $policyDef.displayName
        State         = "enabledForReportingButNotEnforced"
        Conditions    = $policyDef.conditions
        GrantControls = $policyDef.grantControls
    }
 
    if ($policyDef.sessionControls) {
        $params.SessionControls = $policyDef.sessionControls
    }
 
    $newPolicy = New-MgIdentityConditionalAccessPolicy -BodyParameter $params
    Write-Host "CREATED: '$($newPolicy.DisplayName)' in report-only mode"
}

Promote from Report-Only to Enabled

After validating in report-only, promote policies to enabled one layer at a time. Foundation first, then Identity Protection, then Data Protection, then Admin. Leave at least a week between layers.

# Enable a specific policy after validation
$policyName = "BASELINE - Block legacy authentication"
$policy = Get-MgIdentityConditionalAccessPolicy -Filter "displayName eq '$policyName'"
 
Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId $policy.Id `
    -State "enabled"
 
Write-Host "Enabled: $policyName"

An Opinion on CA Policy Management

Once you have a working baseline, the challenge shifts from creation to maintenance. Policies drift. People add exclusions to fix urgent problems and never remove them. New applications get deployed without considering which CA policies should apply to them.

Three practices that keep the policy set honest:

1. Quarterly access review of exclusion groups. Every group that appears in a CA policy exclusion should have an access review. If someone was added to a "CA-Exclude-ComplianceBypass" group six months ago to work around a device issue, and that issue is resolved, the exclusion should be removed. Automate this with Entra ID Access Reviews.

2. Name policies for readability, not cleverness. The naming convention in this post (LAYER - Description) exists so that anyone looking at the policy list can understand the architecture at a glance. Don't name policies after ticket numbers or project codes. Name them after what they do.

3. Version control the JSON. Export your policies weekly to a Git repository. Diff them. When a policy changes unexpectedly, you'll know exactly what changed and when. This is the same principle as Configuration as Code. Your security policy is configuration, and it deserves the same rigour as your infrastructure.


Testing CA Policies Without Locking Everyone Out

The What If tool in the Entra portal is underused. Before enabling any policy, run What If scenarios for:

  • A regular user on a managed device, on-network
  • A regular user on an unmanaged device, off-network
  • A guest user accessing SharePoint
  • A service principal authenticating via app-only auth
  • A break-glass account signing in from an unknown location
  • An admin signing in to the Azure portal

Each scenario should produce the result you expect. If the break-glass account is blocked by any policy, stop. Your exclusion is wrong.

If you're already using Graph API for tenant assessment and automation, you can also automate What If via Graph:

# Simulate a sign-in scenario against CA policies
$params = @{
    ConditionalAccessWhatIfConditions = @{
        UserPrincipalName = "[email protected]"
        CloudAppId        = "00000003-0000-0ff1-ce00-000000000000"  # SharePoint Online
        ClientAppType     = "browser"
        Country           = "GB"
        IpAddress         = "203.0.113.50"
        DevicePlatform    = "windows"
    }
    ConditionalAccessWhatIfSubject = @{
        UserId = "<test-user-object-id>"
    }
}
 
$result = Invoke-MgGraphRequest -Method POST `
    -Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/evaluate" `
    -Body ($params | ConvertTo-Json -Depth 5)
 
$result.value | Select-Object displayName, result | Format-Table -AutoSize

The What If API is now GA in the v1.0 Graph endpoint (/identity/conditionalAccess/evaluate). The schema has stabilised, making it a reliable option for automated regression testing of your CA policies alongside the portal-based What If tool.


Where This Goes Next

This baseline covers the foundational controls: authentication, device posture, identity risk, and data protection. Combined with endpoint standardisation and a solid Configuration as Code pipeline, it forms the identity layer of a Zero Trust architecture. There are more advanced patterns that build on top of it:

  • Token protection: binding tokens to the device they were issued to, preventing token theft and replay attacks
  • Continuous access evaluation (CAE): near-real-time enforcement of policy changes and user risk changes, rather than waiting for token expiry
  • Adaptive session controls: dynamically adjusting session length and persistence based on real-time risk signals
  • Cross-tenant access policies: controlling how your CA policies interact with external organisations in B2B scenarios

Each of these deserves its own post. For now, deploy the baseline, validate it in report-only, promote it layer by layer, and monitor the sign-in logs. A well-configured sixteen-policy baseline will prevent more incidents than a hundred reactive rules added one at a time in response to problems.


The policies in this post prioritise security defaults that work for the majority of organisations. Your compliance requirements, user population, and device management maturity will dictate where you need to adjust. Start with the foundation layer and build up. Trying to deploy all sixteen policies simultaneously is how you end up rolling everything back on day two. As with everything in this blog, I'm providing you with examples - it's up to you to validate all of it before you put it into production, you have been warned.