Series · FleetKeeper: operational automation for Intune-managed Windows fleets · Part 3 of 3
Azure · Bicep · Service Bus · Managed Identity · mTLS · Security
FleetKeeper, part 3: Azure services and device authentication
The Azure services behind FleetKeeper, how device certificates and managed identities are checked, and where script hashes and webhook checks still need other controls.
- Drafted
- Published
- Updated
- Reading time
- 7 min
- Author
- Roberto Gramellini
Source: FleetKeeper (private repository)
- Azure Functions architecture
- mutual TLS
- managed identity
- Service Bus
- Logs Ingestion API
- Bicep
- SSRF
- Intune
FleetKeeper, part 3: Azure services and device authentication
Part 3 of the FleetKeeper series. Earlier parts: the loop and the guardrails.
The scenario
Put it plainly: thousands of devices call a public HTTPS endpoint that can tell them to run PowerShell. I would not sign off on that after seeing only a network diagram. We need to know who can call, what each component is allowed to do, and what gets verified before execution. This part follows those checks through the Azure side.
The shape of the solution
Figure 1: the Azure components and the direction of each call.
The backend uses .NET 10 isolated-worker Azure Functions on Linux. It is split by responsibility rather than putting every job into the internet-facing function:
| Component | Job | Reachable from the internet |
|---|---|---|
| HTTP collector | plan, outcome, state, script and heartbeat | Yes; device routes validate mutual TLS |
| MCP tools | read-only lookups and on-demand requests | Yes; gate with the MCP system key and Entra Easy Auth or an APIM OAuth scope |
| Service Bus collector | rule engine, state updates, ingestion | No |
| DeviceOps | privileged Graph calls | No, fed only by Service Bus |
| Handlers | webhook, runbook and inline script runners | No |
| Portal | Blazor Server, Entra sign-in, live power-flow view | Operators |
Dedicated Service Bus queues carry the slow, privileged, and failure-prone work, with dead-letter handling and bounded delivery attempts. That leaves the HTTP front with a smaller job: validate strictly and respond quickly. I prefer that to making the device wait while several cloud services negotiate among themselves.
Identity: the device is the credential
Devices authenticate over mutual TLS. App Service terminates TLS and forwards the client certificate in a header for backend validation. This depends on a platform setting, not just validator code. The Bicep spells out the invariant:
// Security invariant: App Service must strip/replace caller-supplied
// X-ARR-ClientCert. Keep client certificates enabled; Optional only prevents
// Kudu, health checks and administrative endpoints from being TLS-blocked.
// Device-facing functions still require and validate the forwarded certificate.
clientCertEnabled: true
Disable client certificates and a caller could supply the header themselves. Keep them enabled and the platform overwrites it. Only after that does backend validation have a trustworthy starting point. The validator builds the chain against configured roots, not the machine's general trust store:
var trustAnchors = _trustedIssuerCertificates.Where(IsSelfSigned).ToArray(); if (trustAnchors.Length == 0) return CertificateValidationResult.Fail("No trusted root certificates configured"); var intermediates = _trustedIssuerCertificates.Where(c => !IsSelfSigned(c)).ToArray(); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.AddRange(trustAnchors); chain.ChainPolicy.ExtraStore.AddRange(intermediates); var chainValid = chain.Build(certificate); ... // Check issuer thumbprint in chain (skip element[0] which is the leaf/client cert itself). for (int i = 1; i < chain.ChainElements.Count; i++) { var issuerCert = chain.ChainElements[i].Certificate; if (_allowedIssuerThumbprints.Contains(issuerCert.Thumbprint.ToUpperInvariant())) { issuerFound = true; break; } }
There are several checks here: custom root trust, an allowed issuer in the chain rather than in the leaf position, validity dates, and a client-authentication EKU. A CA certificate is refused as a device certificate too. For the longer explanation of this pattern, including deriving device identity from the certificate, see part 3 of the LogCollector series.
Identity: managed identity inside the cloud boundary
Inside Azure, each workload has its own user-assigned managed identity. Bicep declares the role assignments up front, so permissions are part of the deployment rather than a list to finish later. Service Bus also has local authentication disabled:
properties: {
minimumTlsVersion: '1.2'
disableLocalAuth: true // This Service Bus namespace requires managed identity
}
Key Vault uses RBAC rather than access policies. For Log Analytics, telemetry goes through the Logs Ingestion API with a DCE, DCR, and Entra ID authentication. That is the supported path following the HTTP Data Collector API's retirement in September 2026. There is no workspace shared key in this collector's authentication path.
Supply chain: integrity checks are not provenance
Remediation code comes from versioned blobs. Before accepting a script, the endpoint downloads it to a temporary file and compares its SHA-256 with the plan's digest. Only a match lets it move the file into place:
$tmp = Join-Path $dir ("{0}.{1}.tmp" -f $Version, ([guid]::NewGuid().ToString('N'))) Invoke-WebRequest -Uri $uri -Method Get -Certificate $Certificate -TimeoutSec 60 ` -Headers $headers -OutFile $tmp -UseBasicParsing | Out-Null $actual = Get-IntuneUpFileSha256 -Path $tmp if ($actual -ne $expectedLower) { throw "SHA-256 mismatch for $ScriptId/$Version (expected $expectedLower got $actual)" } Move-Item -Path $tmp -Destination $path -Force
A mismatch throws. There is no "run it anyway" fallback. I would be careful about what we claim from a match, though: the bytes agree with the plan's digest, but both came from the same service boundary. That is not independent proof of authorship. Authenticode enforcement (requireScriptSignature) is optional and off by default. Published script blobs can become immutable after a configurable number of days. Restrict writes to the plan/catalog and script storage, and enable trusted-publisher signing when the signing infrastructure is ready.
The webhook handler checks outbound destinations as well. HTTPS is required by default; hostnames are resolved and private ranges rejected. There is a limit: validation happens before the HTTP request, and the connection is not pinned to the validated address. DNS rebinding remains possible. This is a guardrail, not a complete SSRF boundary. Add outbound network restrictions and destination allow-lists.
internal static bool IsPrivateOrLoopback(IPAddress ip) { if (IPAddress.IsLoopback(ip)) return true; var bytes = ip.GetAddressBytes(); if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) { if (bytes[0] == 10) return true; // 10.0.0.0/8 if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12 if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16 if (bytes[0] == 169 && bytes[1] == 254) return true; // link-local + IMDS } else if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) { if (ip.IsIPv4MappedToIPv6) return IsPrivateOrLoopback(ip.MapToIPv4()); if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) return true; if ((bytes[0] & 0xFE) == 0xFC) return true; // fc00::/7 } return false; }
The range checks include IPv4 loopback, private, and link-local addresses; IPv4-mapped IPv6; and IPv6 link-local, site-local, and unique-local addresses. Covering those ranges is useful, but does not close the validation-to-connection DNS gap. Keep the egress policy.
Network and sizing
Sizing starts with deploymentSize. The profiles are dev, a shared Basic plan for around 5,000 clients; prod-small, Premium with autoscale up to five workers for around 10,000; and prod-large, larger Premium with up to twenty workers for 60,000 and beyond. These are project profiles, not capacity promises.
Optional network layers add private endpoints and private DNS zones for storage, Service Bus, Key Vault, App Configuration, and Log Analytics. Application Gateway with WAF (OWASP and bot rules) and API Management are also available. Teams preferring containers can use the Container Apps variant instead of Functions plans. Deployment is scripted, with SHA-256 verification in the artifact path before deployment.
Limits worth stating
- Mutual TLS is only as good as the certificate issuance behind it; the trust configuration (roots, intermediates, issuer allow-list) is the thing to get right and to monitor. The validator logs critically when trust is incomplete.
- Revocation checking is configurable; if you disable it, you accept the offline-revocation trade-off knowingly.
- Script signing is off by default and the SHA-256 check does not replace it: it proves integrity against the plan, not authorship.
- Sizing numbers are the project's profiles, not a guarantee. Load-test your own fleet.
Wrapping up
The device proves its identity; the server chooses the work; stored state and rollout controls constrain what happens next. That is the arrangement I would take away from this series, not a claim that the diagram makes a deployment secure. Test the boundaries in your own environment. These patterns are starting points, not a security blueprint ready for a signature and three departmental stamps.