Built-in compliance covers encryption, OS version and Defender state, but sooner or later you need a rule that Intune doesn't offer: a security agent above a minimum version, a firewall profile that must stay on, a registry value your auditors care about. Custom compliance settings let you pair a discovery script with a JSON rules file and feed the result into the same compliance state Conditional Access evaluates. In this post I'll walk through the script, the JSON schema, the upload and policy steps, and the error codes you'll meet when the two don't agree.
How it works#
A custom compliance policy has two parts. The discovery script runs on the device and returns the current value of one or more settings. The JSON file declares, for each of those settings, what value counts as compliant and what to tell the user when it doesn't. Each compliance policy supports exactly one script, each script can be attached to one policy, and one script can discover many settings. The custom results merge with the built-in settings into a single compound rule set, so a failed custom rule makes the device noncompliant just like a failed BitLocker check.
Supported platforms per Microsoft Learn are Windows (excluding Home editions) using PowerShell, Linux (Ubuntu Desktop 24.04 LTS and 26.04 LTS, Red Hat Enterprise Linux 9 and 10) using any installed interpreter, and macOS using Bash. On Windows the Intune Management Extension does the work: it's installed automatically if missing, checks for new or updated scripts every eight hours, runs the discovery script every eight hours, and also runs it when a user selects Check Compliance in the Company Portal. A push notification can't trigger it on demand.
Step-by-step#
Step 1: Write the discovery script#
The Windows script must end by emitting a hashtable as a single line of compressed JSON. Every key you reference in the JSON must be present in the output on every device, so always assign a value even when the thing you're looking for is missing:
$fw = Get-NetFirewallProfile -Profile Domain
$agent = Get-Item 'C:\Program Files\Contoso Agent\agent.exe' -ErrorAction SilentlyContinue
$hash = @{
FirewallDomainEnabled = ($fw.Enabled -eq 'True')
ContosoAgentVersion = if ($agent) { $agent.VersionInfo.ProductVersion } else { '0.0.0.0' }
}
return $hash | ConvertTo-Json -CompressExpected output: {"ContosoAgentVersion":"2.1.4.0","FirewallDomainEnabled":true}. Keep it lean: Microsoft limits scripts and their output to 1 MB each and run time to 10 minutes on Windows and macOS (5 minutes on Linux), but more importantly the output that reaches the compliance engine is limited to 2,048 characters. Anything longer is truncated into invalid JSON. If you have many checks, split them across policies.
Step 2: Write the JSON rules file#
{
"Rules": [
{
"SettingName": "FirewallDomainEnabled",
"Operator": "IsEquals",
"DataType": "Boolean",
"Operand": true,
"MoreInfoUrl": "https://intranet.contoso.com/it/firewall",
"RemediationStrings": [
{
"Language": "en_US",
"Title": "Windows Firewall must be on for the domain profile.",
"Description": "Turn the firewall back on or contact the service desk."
}
]
},
{
"SettingName": "ContosoAgentVersion",
"Operator": "GreaterEquals",
"DataType": "Version",
"Operand": "2.1.0.0",
"MoreInfoUrl": "https://intranet.contoso.com/it/agent",
"RemediationStrings": [
{
"Language": "en_US",
"Title": "Contoso Agent is out of date. Version found: {ActualValue}.",
"Description": "Install the latest version from the Company Portal."
}
]
}
]
}The fields, as documented:
| Field | Notes |
|---|---|
SettingName | Must match the key in the script output exactly; it's case-sensitive |
Operator | IsEquals, NotEquals, GreaterThan, GreaterEquals, LessThan, LessEquals |
DataType | Boolean, Int64, Double, String, DateTime, Version |
Operand | The value to compare against; a JSON boolean for Boolean, a string for Version |
MoreInfoUrl | Link shown to the user for guidance |
RemediationStrings | Array of Language, Title and Description; at least one entry for en_US; {ActualValue} inserts the discovered value |
A rules file can be up to 100 KB and contain up to 100 rules.
Step 3: Upload the script#
Go to Endpoint security › Device compliance › Scripts › Add and choose the platform (the same Compliance node is also reachable from Devices › Manage devices › Compliance). Give it a name, paste the script, and for Windows review the three settings: Run this script using the logged on credentials (default No, so it runs as System and falls back to System when nobody is signed in), Enforce script signature check, and Run script in 64 bit PowerShell Host. Set the last one to Yes unless you have a reason not to; the default is the 32-bit host, where registry and file system redirection means HKLM\SOFTWARE and C:\Program Files paths can resolve to their 32-bit equivalents and give you a different answer than you tested with. Intune doesn't validate the script for syntax errors, so test it locally first, and note that the script upload workflow doesn't support scope tags.
Step 4: Create the compliance policy#
In Devices › Manage devices › Compliance › Create policy, pick Windows 10 and later. On Compliance settings, expand Custom Compliance, set Custom compliance to Require, select your discovery script, then upload the JSON under Upload and validate the JSON file with your custom compliance settings. Intune validates the file and renders the rules as a table; fix any problems it reports before continuing. For Linux and macOS, use Add settings, pick Custom Compliance, switch Require Custom Compliance to True, and select the script and rules file. Configure Actions for noncompliance (a grace period of a day or two is kind to users), assign to a pilot group and create.
Verify#
- On a pilot device, run the script in an elevated 64-bit PowerShell session and confirm it returns a single line of valid JSON with every expected key.
- Sync the device, then open Reports › Device compliance, select the Reports tab and the Noncompliant devices and settings tile, choose the OS, and generate the report. Each failing custom setting appears as its own line with the discovered value.
- On the device, the Company Portal shows your remediation title and description for a failing rule, with
{ActualValue}replaced. - Break something deliberately (set the agent version operand higher than what's installed) and confirm the device flips to noncompliant, then restore it and confirm it recovers. Allow up to eight hours for the automatic cycle, or sync from the Company Portal website on Windows to speed it up.
Tips & gotchas#
Four error codes cover almost every custom compliance failure in the device compliance reports:
| Code | Meaning | Usual fix |
|---|---|---|
65007 | Script returned failure | Unhandled exception or non-zero exit; wrap risky cmdlets with -ErrorAction SilentlyContinue and test as System |
65008 | Setting missing in the script result | A JSON SettingName has no matching key, or the case differs |
65009 | Invalid JSON for the discovered setting | Output wasn't compressed to one line, extra text was written to the pipeline, or the 2,048-character limit was exceeded |
65010 | Invalid datatype for the discovered setting | The script returned a string where the JSON declares Boolean or Int64, or a version string that isn't parseable |
- Make
return $hash | ConvertTo-Json -Compressthe last line and don'tWrite-Outputanything else; stray output breaks the JSON. - Avoid nested objects or arrays in the hashtable; keep values scalar so the data types line up with the JSON.
- For
Versioncomparisons, normalise to four parts (2.1.0.0) on both sides; file metadata sometimes carries build suffixes. - Scripts that don't show up for selection, or linger after deletion, usually just need a refresh of the blade; cancel and restart the wizard if that doesn't help. You can't delete a script while a policy references it.
- Linux scripts run in the user's context and can't read settings that need elevation, so plan rules accordingly.
- Pair custom compliance with Conditional Access only after the pilot proves the rule is stable; a flapping script locks users out of everything.