~/2026/08/05/intune-conditional-access-policies-that-don-t-lock-out-your-help-desk.md

Intune: Conditional Access Policies That Don't Lock Out Your Help Desk

---
author: 
date: 
read: 13 min
in:   [engineering]
tags: [intune, entra-id]
---

$ grep -n '^#' post.md

The scariest Conditional Access policy I've ever reviewed wasn't scary because it was too weak. It was scary because it was strong enough to lock out the account that would have fixed it. Requiring compliant devices for every application, for every user, with no break-glass path and no staged rollout, is the kind of policy that reads perfectly in a design document and fails the first time a device compliance signal lags behind reality. Microsoft's own troubleshooting guide lists "all users, all resources, require device to be marked as compliant" as a configuration not to use, because an admin without an enrolled device is blocked from getting back in to change it.

Building Conditional Access and Intune compliance policies that hold up means designing for the failure mode from the start. Below is the baseline I'd put in front of any tenant: seven actual policies modeled on Microsoft's documented templates, the Graph JSON and PowerShell to create them in report-only mode, the queries to read the results, and the access your help desk needs so a block becomes a ticket they can close.

Emergency access accounts come first

Microsoft's current guidance on emergency access accounts is more specific than "two accounts with long passwords in a safe," and it changed in ways that matter:

  • At least two cloud-only accounts on the *.onmicrosoft.com domain, not federated or synced from on-premises, with Global Administrator assigned active permanent (not PIM-eligible).
  • Passwordless, phishing-resistant authentication: a FIDO2 passkey (recommended) or certificate-based authentication. These satisfy the mandatory MFA requirement for Azure and admin portals, and they should be a different method from the one your everyday admins use.
  • Exclude them from Conditional Access policies that block or restrict sign-in. Report-only policies don't block access and don't need the exclusion. Microsoft recommends a dedicated group (for example EmergencyAccess) and excluding that group.
  • Alert on every sign-in and test them at least every 90 days.

The mistake I see most often isn't skipping this. Most tenants have break-glass accounts. It's excluding them from the wrong policies. An account excluded from the MFA requirement but still caught by a device compliance policy is not a break-glass account; it's an account that fails at exactly the moment Intune is the thing that's broken. Exclude the group from every enforced policy, then audit that it stays excluded (the drift script linked at the end checks this).

The alert Microsoft documents is a Log Analytics query on SigninLogs that fires on any result, wired to an Azure Monitor alert rule with a threshold of 0:

kusto
// Any sign-in by an emergency access account (replace with your accounts' object IDs)
SigninLogs
| where UserId == "<emergency-account-1-object-id>" or UserId == "<emergency-account-2-object-id>"
| project TimeGenerated, UserPrincipalName, UserId, IPAddress, ResultType, ResultDescription

One more thing belongs in the break-glass plan now: Microsoft-managed policies. Microsoft creates policies such as "Block legacy authentication," "Multifactor authentication for all users," and "Multifactor authentication for admins accessing Microsoft Admin Portals" in your tenant in report-only, and enables them no less than 30 days later if you leave them there (with email and Message center notice two weeks ahead). You can't rename or delete them, but you can exclude identities and set them to Off. Exclude your emergency access group from them like any other policy, and decide deliberately whether they or your own policies own each control.

Seven baseline policies, all starting in report-only

Every policy below starts in report-only, excludes the emergency access group, and uses a numbered naming convention so sign-in logs, scripts, and conversations can refer to "CA003" without ambiguity. Each one maps to a Microsoft template article (listed in the references).

CA001: Admins require phishing-resistant MFA

SettingValue
Users: includeDirectory roles: Global, Application, Authentication, Billing, Cloud Application, Conditional Access, Exchange, Helpdesk, Password, Privileged Authentication, Privileged Role, Security, SharePoint, and User Administrator
Users: excludeEmergencyAccess group
Target resourcesAll resources
ConditionsNone
GrantRequire authentication strength: Phishing-resistant MFA
Initial stateReport-only

Note Helpdesk Administrator and Password Administrator on that list: your help desk is in scope. Microsoft's caution is blunt: enabling this before administrators register phishing-resistant methods "risks locking you out of your tenant." Issue a Temporary Access Pass so each tech can register a passkey or Windows Hello for Business, and watch report-only results until every admin shows success. Conditional Access only targets built-in role assignments; administrative-unit-scoped and custom roles aren't enforced, so a help desk built on a custom role needs a group-based policy instead.

CA002: Block legacy authentication

SettingValue
Users: includeAll users
Users: excludeEmergencyAccess group, plus any account that must keep legacy auth for now
Target resourcesAll resources
ConditionsClient apps: Exchange ActiveSync clients and Other clients only
GrantBlock access
Initial stateReport-only

Before enforcing, filter the sign-in logs (interactive and non-interactive tabs) on Client app for the legacy protocols to find the multifunction printers and old scripts still using them.

CA003: All users require MFA

SettingValue
Users: includeAll users
Users: excludeEmergencyAccess group; Directory Synchronization Accounts role if you run Entra Connect or Cloud Sync
Target resourcesAll resources, with no exclusions
ConditionsNone
GrantRequire authentication strength: Multifactor authentication
Initial stateReport-only

Microsoft recommends this exact baseline (all users, all resources, no app exclusions). Don't combine Require multifactor authentication and Require authentication strength in one policy; that combination isn't supported. If you use external authentication methods, use the plain MFA grant instead, because they aren't compatible with authentication strengths.

CA004: Require MFA for Azure management

SettingValue
Users: includeAll users
Users: excludeEmergencyAccess group
Target resourcesWindows Azure Service Management API
ConditionsNone
GrantRequire multifactor authentication
Initial stateReport-only

The resource is still called Windows Azure Service Management API in the picker (Azure Government Cloud Management API in Azure Government). It covers Azure Resource Manager and the Azure portal, which includes the Microsoft Entra admin center, and it indirectly affects Azure CLI and Azure PowerShell. It does not apply to Microsoft Graph PowerShell, and it no longer covers Azure DevOps.

CA005: Secure security info registration

SettingValue
Users: includeAll users
Users: excludeAll guest and external users; EmergencyAccess group
Target resourcesUser actions: Register security information
ConditionsLocations: include any location, exclude all trusted locations
GrantRequire authentication strength: Multifactor authentication
Initial stateReport-only

New hires can't satisfy MFA before they've registered anything, so this policy assumes the help desk issues Temporary Access Pass credentials, which satisfy MFA requirements. Microsoft's docs also flag a date to plan around: starting July 6, 2026, policies targeting Register security information also apply to Windows Hello for Business and macOS Platform SSO credential registration, so a grant control that a new Windows user can't meet now blocks Windows Hello enrollment too. Test those flows with a pilot group.

CA006: Windows devices must be compliant or hybrid joined

SettingValue
Users: includeAll users
Users: excludeEmergencyAccess group; Directory Synchronization Accounts role
Target resourcesAll resources
ConditionsDevice platforms: Windows
GrantRequire device to be marked as compliant or Require Microsoft Entra hybrid joined device (require one of the selected controls)
Initial stateReport-only

This is modeled on Microsoft's "compliant or hybrid joined" template, scoped to Windows while it runs in report-only. That scoping is deliberate: Microsoft warns that report-only policies requiring a compliant device can prompt macOS, iOS, and Android users to pick a device certificate on every sign-in, and recommends excluding those platforms from report-only compliance policies. Add them with their own policy once you're ready to enforce. The compliant-device control doesn't block Intune enrollment itself, so new devices can still enroll.

CA007: MFA and reauthentication for risky sign-ins

SettingValue
Users: includeAll users
Users: excludeEmergencyAccess group
Target resourcesAll resources
ConditionsSign-in risk: High and Medium
GrantRequire authentication strength: Multifactor authentication
SessionSign-in frequency: Every time
Initial stateReport-only

This one needs Microsoft Entra ID P2. Users who aren't registered for MFA get blocked on a risky sign-in with AADSTS53004, because the policy prevents registering MFA during a risky session. That's a help desk call you can predict: CA005 plus TAP is how the user gets registered before it happens.

One policy as Microsoft Graph JSON

A Conditional Access policy is a conditionalAccessPolicy resource with displayName, state, conditions, grantControls, and sessionControls. The state values are enabled, disabled, and enabledForReportingButNotEnforced (report-only). This is CA002 as a POST to https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies:

json
{
  "displayName": "CA002 - All users - Block legacy authentication",
  "state": "enabledForReportingButNotEnforced",
  "conditions": {
    "users": {
      "includeUsers": ["All"],
      "excludeGroups": ["<emergency-access-group-object-id>"]
    },
    "applications": {
      "includeApplications": ["All"]
    },
    "clientAppTypes": ["exchangeActiveSync", "other"]
  },
  "grantControls": {
    "operator": "OR",
    "builtInControls": ["block"]
  }
}

CA001 swaps the users block for built-in role template IDs and the grant for an authentication strength reference:

json
{
  "displayName": "CA001 - Admins - Require phishing-resistant MFA",
  "state": "enabledForReportingButNotEnforced",
  "conditions": {
    "users": {
      "includeRoles": [
        "62e90394-69f5-4237-9190-012177145e10",
        "729827e3-9c14-49f7-bb1b-9608f156bbb8",
        "966707d0-3269-4727-9be2-8c3a10f19b9d"
      ],
      "excludeGroups": ["<emergency-access-group-object-id>"]
    },
    "applications": { "includeApplications": ["All"] },
    "clientAppTypes": ["all"]
  },
  "grantControls": {
    "operator": "OR",
    "builtInControls": [],
    "authenticationStrength": { "id": "<phishing-resistant-strength-id>" }
  }
}

Those three role IDs are Global Administrator, Helpdesk Administrator, and Password Administrator; the script below carries all fourteen. Look up the strength ID rather than hard-coding it: Get-MgPolicyAuthenticationStrengthPolicy -Filter "policyType eq 'builtIn'" lists the three built-in strengths.

Create the whole set in report-only with PowerShell

This script creates all seven policies with state set to enabledForReportingButNotEnforced. It resolves the built-in authentication strengths and the Windows Azure Service Management API service principal at run time, skips any policy whose name already exists, and supports -WhatIf. It needs the Microsoft.Graph PowerShell SDK (Microsoft.Graph.Authentication, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Applications) and a Conditional Access Administrator or Security Administrator. The Graph create API requires Policy.Read.All plus Policy.ReadWrite.ConditionalAccess, and Microsoft documents a known issue where it can ask for consent to more than that; Application.Read.All covers the service principal lookup.

powershell
<#
.SYNOPSIS
    Creates a baseline set of Entra ID Conditional Access policies in report-only mode.
.DESCRIPTION
    Resolves the built-in authentication strengths and the Windows Azure Service Management API
    service principal, then creates seven Conditional Access policies (CA001-CA007) with state
    enabledForReportingButNotEnforced. Every policy excludes the emergency access group. Policies
    whose display name already exists are skipped, so the script is safe to rerun.
.PARAMETER EmergencyAccessGroupId
    Object ID of the security group that holds the emergency access (break-glass) accounts.
.PARAMETER ExcludeDirectorySyncRole
    Also exclude the Directory Synchronization Accounts role from the all-users policies. Use it
    when Microsoft Entra Connect or Cloud Sync is in place.
.EXAMPLE
    .\New-CaReportOnlyBaseline.ps1 -EmergencyAccessGroupId "<group-object-id>" -ExcludeDirectorySyncRole -WhatIf
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-08-05)
    Requires: PowerShell 7+, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Applications,
              Conditional Access Administrator or Security Administrator
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param (
    [Parameter(Mandatory = $true)]
    [string]$EmergencyAccessGroupId,

    [switch]$ExcludeDirectorySyncRole
)

$ErrorActionPreference = 'Stop'
Import-Module Microsoft.Graph.Identity.SignIns
Import-Module Microsoft.Graph.Applications

Connect-MgGraph -Scopes 'Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess', 'Application.Read.All' -NoWelcome

$reportOnly = 'enabledForReportingButNotEnforced'

# Built-in role template IDs for the 14 roles in Microsoft's admin MFA templates.
$adminRoles = @(
    '62e90394-69f5-4237-9190-012177145e10',
    '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3',
    'c4e39bd9-1100-46d3-8c65-fb160da0071f',
    'b0f54661-2d74-4c50-afa3-1ec803f12efe',
    '158c047a-c907-4556-b7ef-446551a6b5f7',
    'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9',
    '29232cdf-9323-42fd-ade2-1d097af3e4de',
    '729827e3-9c14-49f7-bb1b-9608f156bbb8',
    '966707d0-3269-4727-9be2-8c3a10f19b9d',
    '7be44c8a-adaf-4e2a-84d6-ab2649e08a13',
    'e8611ab8-c189-46e8-94e1-60213ab1f814',
    '194ae4cb-b126-40b2-bd5b-6091b380977d',
    'f28a1f50-f6e7-4571-818b-6a12f2af6b6c',
    'fe930be7-5e62-47db-91af-98c3a49a38b1'
)

# Directory Synchronization Accounts role template ID.
$syncRole = 'd29b2b05-8046-44ba-8758-1e26182fcf32'

# Resolve the built-in authentication strengths instead of hard-coding their IDs.
$strengths = Get-MgPolicyAuthenticationStrengthPolicy -Filter "policyType eq 'builtIn'" -All
$mfaStrength = $strengths | Where-Object { $_.DisplayName -eq 'Multifactor authentication' } | Select-Object -First 1
$phishStrength = $strengths | Where-Object { $_.DisplayName -like '*phishing*' } | Select-Object -First 1
if (-not $mfaStrength -or -not $phishStrength) {
    throw 'Could not resolve the built-in authentication strengths.'
}

$azureMgmt = Get-MgServicePrincipal -Filter "displayName eq 'Windows Azure Service Management API'" -Property 'appId' |
    Select-Object -First 1

# Scope shared by the all-users policies.
$allUsers = @{
    includeUsers  = @('All')
    excludeGroups = @($EmergencyAccessGroupId)
}
if ($ExcludeDirectorySyncRole) {
    $allUsers.excludeRoles = @($syncRole)
}

$policies = [System.Collections.Generic.List[hashtable]]::new()

$policies.Add(@{
    displayName   = 'CA001 - Admins - Require phishing-resistant MFA'
    state         = $reportOnly
    conditions    = @{
        users          = @{ includeRoles = $adminRoles; excludeGroups = @($EmergencyAccessGroupId) }
        applications   = @{ includeApplications = @('All') }
        clientAppTypes = @('all')
    }
    grantControls = @{ operator = 'OR'; builtInControls = @(); authenticationStrength = @{ id = $phishStrength.Id } }
})

$policies.Add(@{
    displayName   = 'CA002 - All users - Block legacy authentication'
    state         = $reportOnly
    conditions    = @{
        users          = $allUsers
        applications   = @{ includeApplications = @('All') }
        clientAppTypes = @('exchangeActiveSync', 'other')
    }
    grantControls = @{ operator = 'OR'; builtInControls = @('block') }
})

$policies.Add(@{
    displayName   = 'CA003 - All users - Require MFA strength'
    state         = $reportOnly
    conditions    = @{
        users          = $allUsers
        applications   = @{ includeApplications = @('All') }
        clientAppTypes = @('all')
    }
    grantControls = @{ operator = 'OR'; builtInControls = @(); authenticationStrength = @{ id = $mfaStrength.Id } }
})

if ($azureMgmt) {
    $policies.Add(@{
        displayName   = 'CA004 - All users - Require MFA for Azure management'
        state         = $reportOnly
        conditions    = @{
            users          = @{ includeUsers = @('All'); excludeGroups = @($EmergencyAccessGroupId) }
            applications   = @{ includeApplications = @($azureMgmt.AppId) }
            clientAppTypes = @('all')
        }
        grantControls = @{ operator = 'OR'; builtInControls = @('mfa') }
    })
} else {
    Write-Warning 'Windows Azure Service Management API service principal not found; skipping CA004.'
}

$policies.Add(@{
    displayName   = 'CA005 - All users - Secure security info registration'
    state         = $reportOnly
    conditions    = @{
        users          = @{
            includeUsers  = @('All')
            excludeUsers  = @('GuestsOrExternalUsers')
            excludeGroups = @($EmergencyAccessGroupId)
        }
        applications   = @{ includeUserActions = @('urn:user:registersecurityinfo') }
        locations      = @{ includeLocations = @('All'); excludeLocations = @('AllTrusted') }
        clientAppTypes = @('all')
    }
    grantControls = @{ operator = 'OR'; builtInControls = @(); authenticationStrength = @{ id = $mfaStrength.Id } }
})

$policies.Add(@{
    displayName   = 'CA006 - All users - Windows compliant or hybrid joined'
    state         = $reportOnly
    conditions    = @{
        users          = $allUsers
        applications   = @{ includeApplications = @('All') }
        platforms      = @{ includePlatforms = @('windows') }
        clientAppTypes = @('all')
    }
    grantControls = @{ operator = 'OR'; builtInControls = @('compliantDevice', 'domainJoinedDevice') }
})

$policies.Add(@{
    displayName     = 'CA007 - All users - MFA for medium and high sign-in risk'
    state           = $reportOnly
    conditions      = @{
        users            = @{ includeUsers = @('All'); excludeGroups = @($EmergencyAccessGroupId) }
        applications     = @{ includeApplications = @('All') }
        signInRiskLevels = @('high', 'medium')
        clientAppTypes   = @('all')
    }
    grantControls   = @{ operator = 'OR'; builtInControls = @(); authenticationStrength = @{ id = $mfaStrength.Id } }
    sessionControls = @{
        signInFrequency = @{
            isEnabled          = $true
            frequencyInterval  = 'everyTime'
            authenticationType = 'primaryAndSecondaryAuthentication'
        }
    }
})

$existing = @(Get-MgIdentityConditionalAccessPolicy -All | ForEach-Object { $_.DisplayName })

foreach ($policy in $policies) {
    if ($existing -contains $policy.displayName) {
        Write-Warning "Skipping '$($policy.displayName)': a policy with that name already exists."
        continue
    }

    if ($PSCmdlet.ShouldProcess($policy.displayName, 'Create Conditional Access policy in report-only mode')) {
        $created = New-MgIdentityConditionalAccessPolicy -BodyParameter $policy
        [PSCustomObject]@{
            DisplayName = $created.DisplayName
            State       = $created.State
            Id          = $created.Id
        }
    }
}

Run it with -WhatIf first, then for real:

text
DisplayName                                              State                             Id
-----------                                              -----                             --
CA001 - Admins - Require phishing-resistant MFA          enabledForReportingButNotEnforced 00000000-0000-0000-0000-000000000101
CA002 - All users - Block legacy authentication          enabledForReportingButNotEnforced 00000000-0000-0000-0000-000000000102
CA003 - All users - Require MFA strength                 enabledForReportingButNotEnforced 00000000-0000-0000-0000-000000000103
...

Read report-only results before you flip anything

Report-only means Entra evaluates the policy at every sign-in and logs what it would have done, without prompting or blocking. Each policy lands in one of four report-only results in the sign-in log's Report-only tab: Success, Failure (a block, or a device that failed a compliance check), User action required (the user would have been prompted, for MFA for example), and Not applied. "User action required" is the category people skip, and it's the one that turns into Monday-morning tickets: every sign-in there would have been an MFA prompt someone may not be registered for.

Report-only has limits. Microsoft's report-only article lists items in the User actions scope as the exception to what it can evaluate, so don't rely on report-only data alone for CA005; confirm it with a pilot group.

My staged sequence: report-only for at least a full business cycle including whatever shift pattern the affected users work, then review near-misses (users who would have failed, not just users who did), fix the gaps, and enforce for a pilot group before the full population. Three tools help:

  • Policy impact on each policy's page shows potential impact on interactive sign-ins over 24 hours, 7 days, or a month. It needs at least Security Reader.
  • Conditional Access insights and reporting workbook (Entra ID > Conditional Access > Insights and reporting) shows report-only and enforced results side by side for up to 90 days. It needs Entra ID P1, sign-in logs streaming to a Log Analytics workspace, Security Reader, and permissions on that workspace.
  • Log Analytics directly. SigninLogs.ConditionalAccessPolicies is a dynamic array with each policy's displayName and result, using values like reportOnlySuccess, reportOnlyFailure, and reportOnlyNotApplied. I also include reportOnlyInterrupted, which is in Graph's result enum; filtering on it costs nothing.
kusto
// Sign-ins that a report-only CA policy would have blocked or challenged, last 7 days
SigninLogs
| where TimeGenerated > ago(7d)
| mv-expand Policy = ConditionalAccessPolicies
| extend PolicyName = tostring(Policy.displayName), PolicyResult = tostring(Policy.result)
| where PolicyName startswith "CA0"
| where PolicyResult in ("reportOnlyFailure", "reportOnlyInterrupted")
| summarize SignIns = count(), Users = dcount(UserPrincipalName), Apps = make_set(AppDisplayName, 10) by PolicyName, PolicyResult
| order by SignIns desc

Swap the summarize for project TimeGenerated, UserPrincipalName, AppDisplayName, ClientAppUsed, tostring(DeviceDetail.operatingSystem) to get the list of people to contact before you enforce.

Give the help desk a way to see what the user sees

A technician troubleshooting "I can't get into my email" has almost no chance of diagnosing a Conditional Access block without the sign-in log's Conditional Access tab. The common shortcut is to hand tier 1 Reports Reader and point them at the What If tool. That isn't enough, and the permissions reference shows why:

RoleReads sign-in logsReads Conditional Access policies
Reports ReaderYesNo
Security ReaderYesYes (standard properties)
Global ReaderYesYes (all properties), but it's flagged as a privileged role
Helpdesk AdministratorNoNo

Reports Reader can open the sign-in logs, which is why Microsoft's troubleshooting steps start with it. But Graph documents that the applied Conditional Access policies on a sign-in are only returned to roles that can read Conditional Access data (Global Reader, Security Administrator, Security Reader, Conditional Access Administrator); without one, that property is simply left out. The What If page doesn't name a required role, but it evaluates your policy set through the conditionalAccess/evaluate API, which needs Policy.Read.ConditionalAccess, so give tier 1 a role that can read policies. Security Reader is the least-privileged built-in role that covers both halves. It also unlocks Policy impact and, with access to the Log Analytics workspace, the workbook.

Teach the error codes Microsoft lists for Conditional Access: 53000 DeviceNotCompliant, 53001 DeviceNotDomainJoined, 53002 ApplicationUsedIsNotAnApprovedApp, 53003 BlockedByConditionalAccess, 53004 ProofUpBlockedDueToRisk. The user sees them prefixed as AADSTS53003 and so on, and More details on the error page gives the correlation ID to search for.

For techs who'd rather not click through the portal, this script lists one user's recent sign-ins that a policy blocked, or that a report-only policy would have blocked. It uses only Microsoft.Graph.Authentication, needs delegated AuditLog.Read.All and Policy.Read.All (an admin consents once), and a Security Reader role. It sends the Prefer: include-unknown-enum-members header, because without it Graph doesn't return the report-only result values.

powershell
<#
.SYNOPSIS
    Lists a user's recent sign-ins that Conditional Access blocked or that a report-only policy would have blocked.
.DESCRIPTION
    Queries the Microsoft Graph sign-in logs for one user over a recent window and returns one row per
    failing Conditional Access policy result: time, app, error code, failure reason, device OS,
    compliance flag, policy, result, and correlation ID. Sends Prefer: include-unknown-enum-members so
    report-only results (reportOnlyFailure, reportOnlyInterrupted) are returned.
.PARAMETER UserPrincipalName
    UPN of the user who reported the problem.
.PARAMETER Hours
    How many hours back to look. Default 24.
.EXAMPLE
    .\Get-CaSignInFailure.ps1 -UserPrincipalName "user@example.com" -Hours 8
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-08-05)
    Requires: PowerShell 7+, Microsoft.Graph.Authentication, AuditLog.Read.All and Policy.Read.All,
              Security Reader (or another role that reads sign-in logs and Conditional Access data)
#>
[CmdletBinding()]
param (
    [Parameter(Mandatory = $true)]
    [string]$UserPrincipalName,

    [ValidateRange(1, 720)]
    [int]$Hours = 24
)

$ErrorActionPreference = 'Stop'
Import-Module Microsoft.Graph.Authentication
Connect-MgGraph -Scopes 'AuditLog.Read.All', 'Policy.Read.All' -NoWelcome

$since = (Get-Date).ToUniversalTime().AddHours(-$Hours).ToString('yyyy-MM-ddTHH:mm:ssZ')
$upn = $UserPrincipalName.ToLower().Replace("'", "''")
$filter = "userPrincipalName eq '$upn' and createdDateTime ge $since"
$uri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$filter=$([uri]::EscapeDataString($filter))&`$top=100"
$headers = @{ Prefer = 'include-unknown-enum-members' }
$interesting = @('failure', 'reportOnlyFailure', 'reportOnlyInterrupted')

while ($uri) {
    $page = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $headers
    foreach ($signIn in $page['value']) {
        foreach ($policy in $signIn['appliedConditionalAccessPolicies']) {
            if ($interesting -contains $policy['result']) {
                [PSCustomObject]@{
                    Time          = $signIn['createdDateTime']
                    App           = $signIn['appDisplayName']
                    ErrorCode     = $signIn['status']['errorCode']
                    FailureReason = $signIn['status']['failureReason']
                    OS            = $signIn['deviceDetail']['operatingSystem']
                    IsCompliant   = $signIn['deviceDetail']['isCompliant']
                    Policy        = $policy['displayName']
                    Result        = $policy['result']
                    CorrelationId = $signIn['correlationId']
                }
            }
        }
    }
    $uri = $page['@odata.nextLink']
}

If the Policy column comes back empty on every row, the signed-in tech has a role that reads sign-in logs but not Conditional Access data. That's the Reports Reader gap from the table, showing up in practice.

Intune compliance: the grace period is a setting, not a hope

Most "compliance lag" lockouts come from Intune defaults nobody revisited:

  • Every compliance policy includes Mark device noncompliant at 0 days, so a device that fails a rule is noncompliant, and blocked by CA006, immediately. Raising that schedule is the documented way to give users a grace period; the admin center accepts values in 0.25-day steps (0.25 is six hours). Pair it with a Send email to end user action at 0 days so the user hears about it before the block.
  • Mark devices with no compliance policy assigned as defaults to Compliant. Microsoft recommends Not compliant when you use Conditional Access, but flipping it before every device has an assigned policy is its own lockout. Check assignments first.
  • Compliance status validity period defaults to 30 days (1 to 120). A device that doesn't report within it becomes noncompliant, which is how a laptop that sat in a drawer shows up as a help desk call.
  • Compliance is evaluated when the device checks in, so a user who just fixed the problem may still be blocked until the next check-in. The companion script PowerShell: Intune – Force a Compliance Policy Re-evaluation Fleet-Wide shortens that wait.

Treat every policy as reversible until proven otherwise

Never ship a new or modified Conditional Access policy without first confirming how to undo it quickly, with a tested rollback. A policy that can't be reversed in under five minutes by someone other than its author isn't ready to enforce. The fastest rollback that keeps the evidence is flipping the policy back to report-only, which disables enforcement while it keeps logging what the policy would have done:

powershell
Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId "<policy-id>" -State "enabledForReportingButNotEnforced"

Know what rollback doesn't reset. Conditional Access is evaluated when a token is issued. Access tokens last one hour by default, but clients in Continuous Access Evaluation sessions get long-lived tokens of up to 28 hours. Microsoft also documents that Conditional Access policy and group membership changes can take up to a day to take effect at resource providers like Exchange Online and SharePoint Online (optimizations bring policy updates down to about two hours, but not in every scenario). When a change must apply to specific users right away, revoke their sessions (Revoke-MgUserSignInSession -UserId "<user-id>", which needs User.RevokeSessions.All) or use Revoke sessions on the user's profile. And if every admin is locked out, Microsoft's documented last resort is a support request, which is exactly why the emergency access accounts come first.

To catch the slow version of this problem (a "temporary" exclusion that never gets removed, or a break-glass group that quietly drops off a policy), I run PowerShell: Entra ID – Audit Conditional Access Policy Drift on a schedule against a baseline taken right after rollout.

References