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.
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:
- It ran only when the laptop ran. Its certificate sat in its owner's user store, so the task needed that user's session.
- It went stale quietly. The page showed numbers but not their age.
- The credential lived where the script lived. Moving the job to the cloud tempts you to paste a client secret or storage key into a deployment script. Don't.
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:
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.
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:
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.
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:
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".
$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.
Each key maps to one dsregcmd field. The meanings are Microsoft's:
| Key | dsregcmd field | YES means |
|---|---|---|
aadj | AzureAdJoined | The device is joined to Microsoft Entra ID. |
domj | DomainJoined | The device is joined to an Active Directory domain. YES on both keys is hybrid joined. |
tpm | TpmProtected | The device private key is stored in a hardware Trusted Platform Module (TPM). |
ngc | NgcSet | A Windows Hello key is set for the signed-in user, on this device. |
prt | AzureAdPrt | A 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.
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:
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.
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.
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."
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 shows | Where to serve it | Who can read it |
|---|---|---|
| Totals only, with nothing that names a device or user | Storage 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 on | Only 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."
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:
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.
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.
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
- Use Remediations to detect and fix support issues - Microsoft Learn. Output limit, reporting cycle, exit codes.
- Create device compliance policies - Microsoft Learn. Truncated output becomes invalid JSON.
- List deviceHealthScriptDeviceStates - Microsoft Graph beta. The request, permissions and beta warning.
- deviceHealthScriptDeviceState resource type - Microsoft Graph beta. Output, state values, relationships.
- List deviceHealthScripts - Microsoft Graph beta. Finding the remediation ID.
- Paging Microsoft Graph data in your app - Microsoft Learn. Following
@odata.nextLink. - Troubleshoot devices by using the dsregcmd command - Microsoft Learn. Field meanings and user context.
- Azure Automation account authentication overview - Microsoft Learn. Managed identities recommended.
- Using a system-assigned managed identity for an Azure Automation account - Microsoft Learn. Turning it on.
- Assign a managed identity to an application role using PowerShell - Microsoft Learn. The grant and token caching.
- Grant or revoke API permissions programmatically - Microsoft Learn. Scopes an admin needs.
- List appRoleAssignments granted to a service principal - Microsoft Graph. The proof check.
- Authentication module cmdlets in Microsoft Graph PowerShell - Microsoft Learn.
Connect-MgGraph -Identity. - Get managedDevice - Microsoft Graph beta.
deviceNameand its permission. - windowsHelloForBusinessAuthenticationMethod resource type - Microsoft Graph. The Entra-side key.
- Manage schedules in Azure Automation - Microsoft Learn. Schedules and the one-hour minimum.
- Static website hosting in Azure Storage - Microsoft Learn. The
$webcontainer. - Static website hosting FAQ - Microsoft Learn. Anonymous read only.
- Assign an Azure role for access to blob data - Microsoft Learn. Blob data roles.
- Run PowerShell commands with Microsoft Entra credentials to access blob data - Microsoft Learn.
-UseConnectedAccount. - Prevent Shared Key authorization for an Azure Storage account - Microsoft Learn. Turning off keys.
- Manage storage account access keys - Microsoft Learn. Rotating keys.
- Authentication and authorization in Azure App Service - Microsoft Learn. Built-in sign-in.
- Add and manage application credentials in Microsoft Entra ID - Microsoft Learn. Secrets versus certificates.
Related reading on EndpointWeekly
- Get a PRT fleet health report using Graph Explorer - the same data, read by hand.
- WHfB health scripts: six checks - more signals for the JSON line.
- NgcSet says YES and Hello still does not work - triaging one flagged device.
- Run a remediation across a device group with Graph - refresh a group's data now.
- Check Hello keys in Entra with Microsoft Graph - the side this dashboard cannot see.
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.