HomeNewsletterCommunityMVP FeedToolsArchiveBlogToday's NewsAboutServicesQuick Links★ Pro Subscribe free
← Back to Blog
Windows Hello for Business Windows Hello for BusinessIntune RemediationsMicrosoft GraphAzure AutomationManaged IdentityAzure Storage

Self-Refreshing Windows Hello Fleet Dashboard With Intune, Graph and Azure Automation

IA
Imran Awan
31 October 2026

The first Windows Hello for Business (WHfB) fleet report I relied on lived on one laptop. A scheduled task pulled the data every few hours, but only while that laptop was on and its owner was signed in.

When the laptop was off, the report stopped. Nothing on the page said so. It kept showing the last good numbers, and they looked current. This post rebuilds that report so it refreshes itself in Azure, with no laptop, no stored client secret and no storage account key.

The short version

An Intune remediation's detection script writes one compact JSON line per device. Intune keeps that line, and Microsoft Graph returns it as preRemediationDetectionScriptOutput from the beta deviceRunStates endpoint. An Azure Automation runbook signs in with its own managed identity, reads every page of results, and writes a data file to a storage account using a role assignment instead of an account key. Keep the line far below Microsoft's 2,048-character output limit. Put a banner on the page that says what it cannot see: a Hello key deleted in Microsoft Entra ID never shows up on the device.

The problem: a fleet report that lives on one laptop goes stale

A WHfB fleet report answers one question: on how many devices does Hello work today? In a large estate, every device has to report, and the report has to refresh without anyone touching it. The laptop version had three weaknesses:

The rebuild fixes all three, and adds a sentence on the page that says what the data cannot show.

Why it happens: the data lives in three places, and each has limits

Hello health is spread across three systems. The device knows its own state. Intune stores what each device last reported. Microsoft Entra ID holds the Hello keys registered to each user. This is the pipeline in this post:

detection script on device→one JSON line→Intune keeps last output→Graph beta deviceRunStates→runbook with managed identity→blob in storage→page with banner
Hello keys in Entra ID: not read by this pipeline

Limit 1: the output channel is small

A remediation is a script package. Microsoft says it "can contain a detection script only or both a detection script and a remediation script." A detection-only package reads, writes one line of output, and fixes nothing.

That line is capped: "The maximum allowed output size limit is 2,048 characters." The remediation page does not say what happens to longer output. The compliance documentation does, for its discovery scripts: "If the output exceeds this limit, it may be truncated, resulting in invalid JSON". Assume the same risk here. Stay far below the cap, and treat unparseable output as its own state.

Limit 2: the data is not live

Intune does not stream results. Microsoft documents a reporting cycle for recurring scripts: "Within the first six days, the client reports only if a change occurs." It also says: "Every seven days the client sends a report even if there wasn't a change."

So keep the line stable. Microsoft's advice is to "Avoid values that change with every run, such as timestamps." And show each device's last report date on the page, from lastStateUpdateDateTime.

Limit 3: the endpoint is beta

The per-device results come from GET /deviceManagement/deviceHealthScripts/{deviceHealthScriptId}/deviceRunStates on beta. Microsoft warns: "Microsoft supports Intune /beta APIs, but they are subject to more frequent change." Make a renamed field fail loudly in the job output, not as a blank page.

Limit 4: the device cannot see Entra

The detection script reads dsregcmd /status. Microsoft defines NgcSet as "YES if a Windows Hello key is set for the current logged-in user". That is a statement about the device. The Hello key registered for that user in Entra ID is a separate object, a windowsHelloForBusinessAuthenticationMethod in Graph. If someone deletes it in Entra, nothing the detection script reads changes.

⚠ Gotcha: a device-side dashboard cannot report a Hello key deleted in Entra ID. Print that on the page, next to the numbers, so nobody reads "NgcSet YES everywhere" as "Hello works for everyone". The companion post Check Hello keys in Entra with Microsoft Graph covers the Entra side.

How to verify: prove each data source before you build

Before you create any Azure resource, prove each link by hand. Each check here only reads.

Check 1: what dsregcmd shows in a user's session

Microsoft says the user and SSO sections of dsregcmd /status need the user's context: "The command must run in a user context to retrieve a valid status." So the detection script must run as the signed-in user, not as SYSTEM. Run this in a normal, non-elevated PowerShell window on a test device:

PowerShell - read-only, user session (illustrative output)
dsregcmd /status | Select-String -Pattern 'AzureAdJoined|DomainJoined|TpmProtected|NgcSet |AzureAdPrt ' AzureAdJoined : YES DomainJoined : NO TpmProtected : YES NgcSet : YES AzureAdPrt : YES

Good looks like all five lines present. AzureAdJoined : YES with DomainJoined : NO is a Microsoft Entra joined device; YES on both is hybrid joined. Bad looks like a missing NgcSet or AzureAdPrt line. Check that you ran it as the signed-in user, in a window that is not elevated, before you read anything into it.

Check 2: what Graph returns for one remediation

This lists your remediations so you can find the ID of the one you will read. It needs DeviceManagementScripts.Read.All, which Microsoft lists as the least-privileged permission for both calls in this post. It only reads.

PowerShell - read-only Graph, delegated sign-in (illustrative output)
Connect-MgGraph -Scopes 'DeviceManagementScripts.Read.All' $list = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts' $list.value | Where-Object { $_.displayName -eq 'WHfB fleet line' } | ForEach-Object { $_.id } aaaaaaaa-0b0b-1c1c-2d2d-333333333333

Good looks like one ID. Bad looks like nothing, which means the name is wrong, or two IDs, which means two packages share a name. Pick by ID from then on, never by name.

Now read the first page of per-device results for that ID:

PowerShell - first page of deviceRunStates, read-only (illustrative output)
$scriptId = 'aaaaaaaa-0b0b-1c1c-2d2d-333333333333' $uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/' + $scriptId + '/deviceRunStates?$expand=managedDevice' $page = Invoke-MgGraphRequest -Method GET -Uri $uri $page.value[0] | Select-Object detectionState, lastStateUpdateDateTime, preRemediationDetectionScriptOutput detectionState : success lastStateUpdateDateTime : 2026-10-30T07:14:02Z preRemediationDetectionScriptOutput : {"v":1,"aadj":"YES","domj":"NO","tpm":"YES","ngc":"YES","prt":"YES"}

Good looks like JSON in preRemediationDetectionScriptOutput. Bad looks like an empty output field on many rows: those devices have not run the script yet, or it printed nothing.

Microsoft lists the detectionState values as unknown, success, fail, scriptError, pending and notApplicable. Show scriptError and pending separately on the page, not as "unhealthy".

📋 Note: why $expand=managedDevice. The run state has no device name. Microsoft lists managedDevice as a relationship: "The managed device on which the device health script executed". Expanding it returns the device record in the same response, so you avoid one call per device. If your tenant rejects the expand, drop it and look devices up separately.

The fix: a detection line, a runbook with a managed identity, and a page

Step 1: the detection script writes one JSON line

This script reads dsregcmd /status and prints one compact JSON object. It writes nothing to the device. It exits 1 when Hello or the Primary Refresh Token (PRT) is missing. Microsoft's rule is that the detection script "must use exit code exit 1 if the target issue is detected", so Intune reports those devices as having the issue. Because the package has no remediation script, nothing runs as a result.

Detect-WhfbFleetLine.ps1 - detection only, writes nothing
# Settings: Run this script using the logged-on credentials = Yes # No remediation script in the package. This only reports. $raw = (dsregcmd /status) -join "`n" function Get-DsregField([string]$Name) { # [ \t] instead of \s, so an empty field cannot read the next line $m = [regex]::Match($raw, '(?m)^[ \t]*' + $Name + '[ \t]*:[ \t]*([^\r\n]*)') if ($m.Success) { return $m.Groups[1].Value.Trim() } return 'MISSING' } $line = [ordered]@{ v = 1 aadj = Get-DsregField 'AzureAdJoined' domj = Get-DsregField 'DomainJoined' tpm = Get-DsregField 'TpmProtected' ngc = Get-DsregField 'NgcSet' prt = Get-DsregField 'AzureAdPrt' } # No timestamps: a value that changes every run makes every run a "change". $json = $line | ConvertTo-Json -Compress if ($json.Length -gt 1500) { $json = '{"v":1,"err":"too-long"}' } Write-Output $json if ($line.ngc -eq 'YES' -and $line.prt -eq 'YES') { exit 0 } exit 1

Each key maps to one dsregcmd field. The meanings are Microsoft's:

Keydsregcmd fieldYES means
aadjAzureAdJoinedThe device is joined to Microsoft Entra ID.
domjDomainJoinedThe device is joined to an Active Directory domain. YES on both keys is hybrid joined.
tpmTpmProtectedThe device private key is stored in a hardware Trusted Platform Module (TPM).
ngcNgcSetA Windows Hello key is set for the signed-in user, on this device.
prtAzureAdPrtA PRT is present on the device for the signed-in user.

A value of MISSING means the field was not in the output at all. Report it as its own state. Do not count it as NO.

The output is about 70 characters. If a future edit makes it too long, the device reports a short, valid error object instead of broken JSON. Leave user names out: Microsoft says "Don't use scripts to collect personal data from devices". Graph already tells you which device each row belongs to.

✅ Tip: before you upload it, run the script once by hand in a normal, non-elevated PowerShell window as a signed-in user, and pipe the result to ConvertFrom-Json. If that prints the five keys, the runbook will be able to parse every device's line. If it throws, fix the script now, not after a day of unreadable results.

Upload it under Devices › Manage devices › Scripts and remediations, set Run this script using the logged-on credentials to Yes, and assign a Daily schedule. Microsoft advises "less frequent schedules" for nonurgent scripts, and reporting is not urgent.

Step 2: give the Automation account its own identity

A managed identity is an Entra ID identity that Azure creates and looks after for a resource. Microsoft says it "is managed by the Azure platform and doesn't require you to provision or rotate any secrets", and "Managed identities are the recommended way to authenticate in your runbooks". Turn it on:

Automation account › Account Settings › Identity › System assigned: On
Save, then Yes to confirm.
Object (principal) ID: copy it. You need it for the Graph and storage role assignments.

Now grant a Graph application permission. Application permissions are app roles on the Microsoft Graph service principal, assigned with New-MgServicePrincipalAppRoleAssignment. Microsoft's guide covers this case: "in the case that you want to grant the managed identity access to the Microsoft Graph API." This step changes the tenant, so an administrator runs it once.

PowerShell - one-time admin step, grants one read permission
Connect-MgGraph -Scopes 'Application.Read.All','AppRoleAssignment.ReadWrite.All' $miObjectId = 'bbbbbbbb-1c1c-2d2d-3e3e-444444444444' # from the Identity page $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" $role = $graphSp.AppRoles | Where-Object { $_.Value -eq 'DeviceManagementScripts.Read.All' } New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $graphSp.Id ` -PrincipalId $miObjectId -ResourceId $graphSp.Id -AppRoleId $role.Id

Add DeviceManagementManagedDevices.Read.All the same way if your tenant needs it for the expand. Grant the identity nothing with ReadWrite in its name: the job only reads.

⚠ Gotcha: the first run after the grant may still fail. Microsoft warns that managed identity tokens "are cached by the underlying infrastructure. This means that any changes to the managed identity's roles can take significant time to process." Wait and retry before you change permissions.

Step 3: the runbook reads Graph and writes one blob

Assign the identity Storage Blob Data Contributor, scoped to the one container it writes to. Entra decides who can write, and no account key is involved. Microsoft notes that "Azure role assignments may take a few minutes to propagate".

Then, in the storage account, go to Settings › Configuration and set Allow storage account key access to Disabled. Microsoft: "When you disallow Shared Key authorization for a storage account, Azure Storage rejects all subsequent requests" signed with the account keys.

The runbook reads every page of run states and writes one JSON file. Microsoft's paging rule: "you must continue to call Microsoft Graph with the @odata.nextLink property returned in each response until the @odata.nextLink property is no longer returned." Use the whole link: "Don't try to extract the $skiptoken or $skip value and use it in a different request."

Publish-WhfbFleet.ps1 - Automation runbook, PowerShell 7 runtime
# No secrets in this file. Both sign-ins use the managed identity. Disable-AzContextAutosave -Scope Process | Out-Null Connect-AzAccount -Identity | Out-Null Connect-MgGraph -Identity $scriptId = 'aaaaaaaa-0b0b-1c1c-2d2d-333333333333' $uri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/' + $scriptId + '/deviceRunStates?$expand=managedDevice' $rows = @() while ($uri) { $page = Invoke-MgGraphRequest -Method GET -Uri $uri $rows += $page.value $uri = $page.'@odata.nextLink' } $fleet = foreach ($r in $rows) { $d = $null $text = [string]$r.preRemediationDetectionScriptOutput if ($text) { try { $d = $text | ConvertFrom-Json -ErrorAction Stop } catch { $d = $null } } $ngc = 'UNPARSED'; $prt = 'UNPARSED' if ($d -and -not $d.err) { $ngc = $d.ngc; $prt = $d.prt } [pscustomobject]@{ device = $r.managedDevice.deviceName state = $r.detectionState lastReport= $r.lastStateUpdateDateTime ngc = $ngc prt = $prt } } if ($fleet.Count -eq 0) { throw 'No rows returned. Not publishing an empty report.' } $out = [ordered]@{ generatedUtc = (Get-Date).ToUniversalTime().ToString('o') source = 'device-reported via Intune remediation' blindSpot = 'Does not read Hello keys registered in Entra ID' rows = $fleet } $path = Join-Path ([System.IO.Path]::GetTempPath()) 'fleet.json' # UTF-8 without a byte order mark, so the browser's JSON parser accepts it [System.IO.File]::WriteAllText($path, ($out | ConvertTo-Json -Depth 4), (New-Object System.Text.UTF8Encoding($false))) $ctx = New-AzStorageContext -StorageAccountName 'stwhfbcontoso' -UseConnectedAccount Set-AzStorageBlobContent -File $path -Container 'fleet' -Blob 'fleet.json' -Context $ctx -Force | Out-Null Write-Output ('Published ' + $fleet.Count + ' rows at ' + $out.generatedUtc)

Connect-AzAccount -Identity and Connect-MgGraph -Identity both sign in as the system-assigned managed identity. Neither takes a secret.

Three choices come from lessons learned. The runbook refuses to publish an empty report, so a failed pull never replaces good data. It marks unreadable output UNPARSED instead of dropping the row. And it writes UTF-8 without a byte order mark: on the build I learned from, a byte order mark made the page's JSON parsing fail.

Create a schedule under Shared Resources › Schedules and link the runbook to it. Microsoft says "The most frequent interval for which a schedule in Azure Automation can be configured is one hour." Every few hours is plenty, given Intune's reporting cycle.

Step 4: serve the page, and decide who may read it

Azure Storage can host a static website, served from a container named $web. It is simple. It is also public: Microsoft's FAQ says a static website "only supports anonymous public read access for files in the $web container." So match the hosting to the data:

What the page showsWhere to serve itWho can read it
Totals only, with nothing that names a device or userStorage static website ($web)Anyone with the link
Per-device rows (device names, states, dates)A private container read through Entra sign-in, or a web app with App Service authentication turned onOnly people you grant a role to

For per-device data, keep fleet.json in a private container and grant readers Storage Blob Data Reader: the same role-based model, with no key to hand out. For a public totals page, have the runbook write a second file with counts only into $web. Turning off anonymous access on the account does not protect $web: Microsoft says that setting "doesn't affect static websites that are hosted in that storage account."

For a per-device web page, use Azure App Service, which has "built-in authentication (signing in users) and authorization" with Microsoft Entra as a provider. Set Action to take when request is not authenticated to require sign-in. Test from a private browser window: you should land on the Microsoft sign-in page.

Wherever it is hosted, the page must show three things above the numbers: when the data was generated, how many devices have not reported in seven days, and the blind spot in plain words: "This page shows what devices report about themselves. It does not check Hello keys registered in Entra ID."

⚠ Warning: secrets in deployment scripts. Never keep a storage account key, connection string or client secret in plain text in a deployment script, runbook or repository. This design needs none of them. If one was ever written into a script, treat it as exposed and rotate it now. For a storage key, use Regenerate under Security + networking › Access keys, then disable key access if nothing needs it. For a client secret, create a new one under Certificates & secrets, move anything still using the old one, then delete the old one. Better, retire it: Microsoft says "Client secrets are less secure than certificate or federated credentials". Keys that must exist: "regularly rotate and regenerate your keys".

Proof it worked: job output, role assignments and the banner

The job ran without a secret

Start the runbook once by hand, then open the job's Output tab. Good looks like this:

Automation job output (illustrative)
Published 412 rows at 2026-10-31T06:00:11.4820193Z

Bad looks like an authorization error from Graph (the app role is missing or not yet in the token) or from storage (the data role is missing or on the wrong container). "No rows returned" means Graph answered with nothing: check the script ID.

The identity has only what it needs

This lists the identity's app roles with GET /servicePrincipals/{id}/appRoleAssignments and turns each role ID into its name. It only reads.

PowerShell - read-only check of the identity's Graph roles (illustrative output)
$miObjectId = 'bbbbbbbb-1c1c-2d2d-3e3e-444444444444' $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" $granted = Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/servicePrincipals/' + $miObjectId + '/appRoleAssignments') $granted.value | ForEach-Object { $id = $_.appRoleId ($graphSp.AppRoles | Where-Object { $_.Id -eq $id }).Value } DeviceManagementScripts.Read.All

Good looks like read permissions only. Bad looks like anything ending in ReadWrite.All, or a role you do not recognise. Remove those.

The storage account refuses keys

This reads the account's Shared Key setting. It changes nothing.

PowerShell - read-only storage check (illustrative output)
(Get-AzStorageAccount -ResourceGroupName 'rg-whfb-report' -Name 'stwhfbcontoso').AllowSharedKeyAccess False

Good is False. Empty is not good: Microsoft says the account "permits requests that are authorized with Shared Key when the property value is null or when it is true." Then search your deployment scripts for AccountKey= and ClientSecret. Every hit is a credential to rotate.

The page tells the truth about itself

Open the page and check three things. The generated time matches the last job. Devices with an old lastStateUpdateDateTime are counted apart from healthy ones. The blind-spot sentence is visible without scrolling.

Then check the blind spot from the Entra side. Pick a device showing ngc: YES and read its user's registered Hello keys with the companion post's read-only query. If Entra lists no key, the dashboard still shows green. That is expected, and it is why the banner exists. Do not delete a key to test this: that changes a real account.

References

Related reading on EndpointWeekly

All output in this post is illustrative, with invented IDs and counts. No commands were run against a real tenant for this post. Microsoft facts were checked against the pages above on 31 October 2026.

Was this post helpful?
React below — no account needed
Share this post
LinkedIn X / Twitter Reddit Bluesky

More from EndpointWeekly

Windows Hello for Business
Check Windows Hello for Business Keys in Entra ID With Microsoft…
NgcSet : YES only proves the laptop has a Hello key. Read the cloud half with Microsoft…
Windows Hello for Business
Windows Hello and PRT remediations: what is safe to automate and…
Clear-Tpm, Ngc folder deletes, certutil, WAM purges and dsregcmd /leave destroy state.…
Windows Hello for Business
Intune Says the Hello Policy Applied. Here Is How to Prove It on…
Windows Hello for Business does not write to PolicyManager like other CSP policies, so…