PS C:\Blog\rksolutions> cd ..

Thought Global Reader Could Read Everything? Intune Says 401

· 8 min read ·
Intune Entra ID Graph API PowerShell Troubleshooting

I help out on this tenant now and then. Someone wants a second pair of eyes on a configuration, I look at it and say whether it can be tightened up or not, and for that I hold Global Reader and nothing else. This time I wanted a quick read on the overall state of the environment, and the fastest way I know to get that is to run Get-IntuneAnomaliesReport from my own RKSolutions module and see what comes back: stale devices, failed deployments, BitLocker escrow gaps, the usual.

The report returned zero devices and a 401 Unauthorized on every deviceManagement endpoint it touched. That struck me as odd, so I went digging into what the problem actually was.

Table of Contents

What I found

The 401 was never about authentication, and nothing in the status line tells you that. Here is how the error actually reads, what the checks eliminate, and where they run out of road.

The error that sends you the wrong way

This is what the report printed, with the tenant-specific parts removed:

Starting device data collection...
Fetching Autopilot devices...
Processing 0 devices...
Processed 0 devices out of 0 total devices
Error: GET https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/
HTTP/1.1 401 Unauthorized

The useful part is not in that status line. It is in the response body, double-escaped, several layers deep. The real thing arrives as a single line; the indentation is mine:

{
  "error": {
    "code": "UnknownError",
    "message": "{
      \"ErrorCode\": \"Forbidden\",
      \"Message\": \"{
        \"_version\": 3,
        \"Message\": \"An error has occurred - Operation ID (for customer support): 00000000-0000-0000-0000-000000000000 - Url: https://proxy.msubXX.manage.microsoft.com/DeviceEnrollmentFE/StatelessDeviceEnrollmentFEService/deviceManagement/windowsAutopilotDeploymentProfiles?api-version=5025-11-25\",
        \"HttpHeaders\": \"{\"WWW-Authenticate\":\"Bearer\"}\"
      }\"
    }"
  }
}

Two signals in there change the diagnosis:

Signal What it means How I know
The body says "ErrorCode":"Forbidden" while the HTTP status line says 401 These are two different failures. 401 means “I do not know who you are”. Forbidden means “I know who you are, and no”. The inner string is Intune’s own error code, and it is the one telling the truth. Note there is no literal 403 anywhere in the payload - that mismatch is exactly what makes this confusing. The two statuses are defined in RFC 9110, §15.5.2 for 401 and §15.5.4 for 403. That Graph surfaces an Intune authorization refusal under a 401 status is my observation, not documented behavior.
The host is *.manage.microsoft.com The request left Graph and reached the Intune service itself. Your token was accepted; Intune is the component that said no. Microsoft documents manage.microsoft.com and *.manage.microsoft.com as the Intune client and host service endpoint, set ID 163, in Network endpoints for Microsoft Intune.

The nesting is hostile enough - a JSON string inside a JSON string inside a JSON string - that it is worth having something dig it out:

try {
    Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/beta/deviceManagement' -ErrorAction Stop
} catch {
    $outer = $_.ErrorDetails.Message | ConvertFrom-Json
    $inner = $outer.error.message     | ConvertFrom-Json
    $body  = ($inner.Message | ConvertFrom-Json).Message
    if ($body -match 'Url:\s*(https?://[^/?\s]+)') { $refusedBy = $Matches[1] }
    [pscustomobject]@{
        OuterCode = $outer.error.code
        InnerCode = $inner.ErrorCode
        RefusedBy = $refusedBy
    }
}
OuterCode    InnerCode  RefusedBy
---------    ---------  ---------
UnknownError Forbidden  https://proxy.msubXX.manage.microsoft.com

Honest, at least. It tells you who said no. It does not tell you why, and that is as far as Graph is going to take you.

Running the checks

So the next question is whether this is the token, the tenant, or the identity. Here is that as one read-only script - every call is a GET - which also installs the Graph authentication module if you do not have it.

#Requires -Version 5.1
# Intune access diagnostic. Read-only: every call is a GET.

if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
    Write-Host 'Installing Microsoft.Graph.Authentication for the current user...' -ForegroundColor Yellow
    Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
}
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop

# Fresh token: -Scopes is ignored on an existing session.
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Connect-MgGraph -NoWelcome -Scopes @(
    'DeviceManagementServiceConfig.Read.All'
    'Directory.Read.All'
    'Organization.Read.All'
    'User.Read'
)

function Get-Graph {
    param([Parameter(Mandatory)][string]$Uri)
    try { Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject -ErrorAction Stop }
    catch { $script:LastError = $_; $null }
}

function Get-IntuneRefusal {
    param($ErrorRecord)
    $out = [pscustomobject]@{ OuterCode = $null; InnerCode = $null; RefusedBy = $null }
    if (-not $ErrorRecord) { return $out }
    try {
        $outer = $ErrorRecord.ErrorDetails.Message | ConvertFrom-Json
        $out.OuterCode = $outer.error.code
        $inner = $outer.error.message | ConvertFrom-Json
        $out.InnerCode = $inner.ErrorCode
        $body = ($inner.Message | ConvertFrom-Json).Message
        if ($body -match 'Url:\s*(https?://[^/?\s]+)') { $out.RefusedBy = $Matches[1] }
    } catch {
        $out.OuterCode = $ErrorRecord.Exception.Message
    }
    $out
}

$ctx = Get-MgContext

$dm      = Get-Graph 'https://graph.microsoft.com/beta/deviceManagement'
$refusal = Get-IntuneRefusal $script:LastError

$skus  = (Get-Graph 'https://graph.microsoft.com/v1.0/subscribedSkus').value
$roles = (Get-Graph 'https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole').value

$groupType = '#microsoft.graph.group'
$direct = @((Get-Graph 'https://graph.microsoft.com/v1.0/me/memberOf').value |
    Where-Object { $_.'@odata.type' -eq $groupType })
$trans = @((Get-Graph 'https://graph.microsoft.com/v1.0/me/transitiveMemberOf').value |
    Where-Object { $_.'@odata.type' -eq $groupType })

$result = [pscustomobject]@{
    Account         = $ctx.Account
    AuthType        = $ctx.AuthType
    ScopeInToken    = $ctx.Scopes -contains 'DeviceManagementServiceConfig.Read.All'
    IntuneReadable  = [bool]$dm
    OuterErrorCode  = $refusal.OuterCode
    InnerErrorCode  = $refusal.InnerCode
    RefusedBy       = $refusal.RefusedBy
    TenantHasIntune = [bool]($skus.servicePlans | Where-Object {
                          $_.servicePlanName -like '*INTUNE*' -and $_.provisioningStatus -eq 'Success' })
    AccountLicensed = [bool](Get-Graph 'https://graph.microsoft.com/v1.0/me/licenseDetails').value
    ActiveRoles     = $roles.displayName -join ', '
    InNestedGroup   = $trans.Count -gt $direct.Count
    TenantCreated   = (Get-Graph 'https://graph.microsoft.com/v1.0/organization').value.createdDateTime
}

$result | Format-List

if ($result.IntuneReadable) {
    $next = 'Intune is readable. Nothing to fix here.'
} elseif (-not $result.ScopeInToken) {
    $next = 'Reconnect: the scope never made it into the token.'
} elseif (-not $result.TenantHasIntune) {
    $next = 'No Intune service plan is provisioned in this tenant. Fix licensing, not RBAC.'
} elseif ($result.InNestedGroup) {
    $next = 'You reach your groups only through nesting. Nested members are excluded ' +
            'from unlicensed admin access: add a direct membership, or assign a license.'
} else {
    $next = 'Token and tenant are fine, so this is entitlement. Add this account to an ' +
            'Entra security group that carries an Intune RBAC role assignment. ' +
            'Read Only Operator covers read-only reporting.'
}

[pscustomobject]@{ Verdict = $next } | Format-List

Directory.Read.All and Organization.Read.All need admin consent on the Microsoft Graph PowerShell app. Without it those fields come back empty and the IntuneReadable verdict still works.

Against my tenant, with values redacted or shifted where they would identify it:

Account         : <upn>
AuthType        : Delegated
ScopeInToken    : True
IntuneReadable  : False
OuterErrorCode  : UnknownError
InnerErrorCode  : Forbidden
RefusedBy       : https://proxy.msubXX.manage.microsoft.com
TenantHasIntune : True
AccountLicensed : False
ActiveRoles     : Global Reader
InNestedGroup   : False
TenantCreated   : 8 March 2022

Verdict : Token and tenant are fine, so this is entitlement. Add this account to an
          Entra security group that carries an Intune RBAC role assignment.
          Read Only Operator covers read-only reporting.

That output rules out everything ordinary. The scope is in the token. Intune is provisioned in the tenant. The role is present and currently active - transitiveMemberOf/microsoft.graph.directoryRole returns only roles that are active right now, so a PIM-eligible role you have not activated will not appear there, which makes that line double as proof my activation landed. Nested group membership, the one exclusion Microsoft documents for unlicensed admin access on both the licensing page and the RBAC overview, does not apply.

Which leaves the last two lines standing on their own: no license, and nothing granting Intune access in its place.

The chicken-and-egg problem

The obvious next move is to ask Graph what Intune role assignments the account has:

Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/beta/deviceManagement/roleAssignments'

Which fails with the same Forbidden, because reading Intune RBAC requires Intune access. You cannot use Graph to diagnose the absence of the very thing that grants you Graph access here, and this is where the script stops being able to help.

There are two ways around it, both outside Graph. The admin center’s Tenant administration > Roles > My permissions renders the effective permissions for the signed-in account and needs no Intune permission of its own. And opening intune.microsoft.com as the same account separates an identity problem from a token or client problem, because the portal and Graph share one RBAC evaluation. It refused me there too, on every workload. At that point you can stop debugging code.

Where Microsoft’s own docs disagree

This is the part that cost the most time, and it is not one wrong sentence. It is three statements describing one feature three different ways.

Statement one, from the Intune licensing page:

“Administrators can sign in to and manage Microsoft Intune without an assigned Intune license. This access is enabled by default for tenants created after July 2021 and applies to all administrator roles, including Intune administrators and Microsoft Entra administrators.”

That one is checkable, and the script above already did it. (Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/organization').value.createdDateTime returned 8 March 2022 for this tenant - comfortably after the cutoff. So by this page, unlicensed admin access has been on by default since day one here, nobody ever had to flip a toggle, and the documented “up to 48 hours for access changes to take effect” clock is not running either.

Statement two, from the RBAC overview, about the same feature:

“In June 2021, Intune began supporting unlicensed admins. User accounts created after this change can administer Intune without an assigned license.”

That is not the same gate. One keys off when the tenant was created, the other off when the user account was created, and the two dates are a month apart.

Statement three is two paragraphs apart on that same licensing page:

“An Intune license is required for any user or device that benefits directly or indirectly from the Microsoft Intune service, including access through a Microsoft API.”

“Unlicensed admin access grants sign-in and management access to the Microsoft Intune admin center.”

Read together, those say unlicensed admin access covers the admin center and that API access needs a license - a significant thing to leave implicit on a page whose headline sentence is “applies to all administrator roles”. It still does not explain my case, because the portal refused me too.

What the admin center says instead

The Intune admin center has its own wording for the same feature, under Tenant administration > Roles > Administrator Licensing:

“All unlicensed admins have access to Intune. To revoke access from an unlicensed admin, remove them as a member from their Microsoft Entra group assigned to Intune role.”

That names a mechanism none of the Learn pages state: entitlement is carried by group membership, not by holding an admin role.

One documented fact sits behind it. An Intune role is never attached to a person - the assignment always targets a group:

“Both Intune custom and built-in roles are assigned to groups of users.” - RBAC overview

So “a group that carries an Intune role” is the only shape an Intune role assignment can take, and being in that group is the only way to be on the receiving end of one. The licensing page then counts unlicensed admins per security group rather than per admin, with a documented limit of 1000, which is what you would expect if the group is what the entitlement travels through.

Which matches what I measured. No such group, no entitlement, so Intune fell back to requiring a license the account did not have.

The fix

Two options, and only two.

Option Effect Cost
Add the account to an Entra security group that carries an Intune RBAC role Entitlement arrives with the role assignment None
Assign the account an Intune license The existing Global Reader starts working A license, and a security problem - see below

For read-only reporting, Read Only Operator is the right role. Microsoft describes it as “Read Only Operators view user, device, enrollment, configuration and application information and cannot make changes to Intune”, and its permission table grants Enrollment programs | Read profile - exactly what the Autopilot call needed - alongside Read device and Read token, and nothing at all that can write. This is also the role I would have picked over Global Reader in the first place, if the tenant had offered me the choice.

Three details that are easy to get wrong. You cannot assign an Intune role to a user, so you are creating or reusing a group whether you wanted to or not. Direct membership only: members of nested groups are excluded and still need a license. And give it time: Microsoft documents up to 48 hours for unlicensed admin access changes to take effect, so a refusal in the first minutes after adding the group proves nothing yet.

ADVICE: do not take the license option. Admin accounts should be clean: no mailbox, no OneDrive, no Teams presence. That is blast radius, not bureaucracy. An admin account with a mailbox can be phished, can have inbox forwarding rules quietly added to it, shows up in address lists, and gives an attacker who lands on it somewhere to stage data. Most Intune-bearing bundles light all of that up. It trades a five-minute RBAC fix for a permanent increase in what that account is worth to an attacker.

Conclusion

Intune refusals through Graph lie to you twice. The status line says authentication when the problem is authorization, and the error body hides the one detail that matters - a manage.microsoft.com host, meaning your token was accepted and the Intune service is what turned you away. Get past those two and the diagnosis takes minutes: ask for /beta/deviceManagement on its own, open the portal as the same account, and if both turn you away, stop looking at your code and start looking at role assignments.

The deeper trap is that “applies to all administrator roles” reads like a guarantee. Across three Learn statements it is not a consistent one, and none of them mention the mechanism the admin center states outright: entitlement rides on membership of an Entra security group carrying an Intune RBAC role assignment. An Entra admin role on its own buys you nothing in Intune. Give the account a group with Read Only Operator, not a license, and keep your admin identities free of mailboxes.

References

back to all posts next: Assuming Intune Available Apps Auto-Update? Think...
PS Select-String -Pattern
↑↓navigate open escclose