Series · LogCollector: authenticated telemetry for Intune devices · Part 3 of 3
Intune · Microsoft Entra ID · mTLS · PKI · Azure Monitor
LogCollector, part 3: certificates and device identity
How enterprise PKI and Intune MDM certificates pass separate trust checks, resolve to an Entra device ID and face device-binding and Graph authorization checks.
- Drafted
- Published
- Updated
- Reading time
- 10 min
- Author
- Roberto Gramellini
Source:
LogCollector
· revision 9768c522
- certificate-based authentication
- CBA
- Intune MDM device certificate
- Entra device ID
- OID 1.2.840.113556.5.25
- X509Chain
- chain of trust
- enterprise PKI
- mTLS
- Logs Ingestion API
- device binding
Before deploying the telemetry client, I would ask which certificate it will find on the device. Not which one the design document says should be there. If only part of the fleet received the expected template, a perfectly good pipeline will still reject the rest.
This is part 3 of LogCollector. Part 1 covered the move from the HTTP Data Collector API, retired in September 2026, and its workspace key to per-request device identity. Part 2 followed the offline spool. Here the question is narrower: does the frontend trust this certificate, and which device does it identify? Those are related checks, not one check.
The problem: not every device has "the" certificate
A client certificate from your own enterprise PKI is the clean option. An NDES/SCEP or PKCS profile through Intune can issue it, you control the template, and it chains to your root. You can include the device ID too. This is the familiar certificate-based authentication (CBA) model.
The difficulty is assuming every device has reached that happy state. A tenant can also contain:
- new enrolments or devices still in autopilot that have not received the PKI profile;
- devices whose profile deployment failed without attracting attention;
- no internal PKI in the first place.
An Intune-enrolled Windows device already has a certificate for talking to the management service: its Intune MDM device certificate. It is issued by an Intune CA, unique to the device, and protected there. It is already available without issuing another paid-for credential. That is convenient, but convenience is not yet a trust policy.
LogCollector supports both through explicit trust tiers. Both must resolve to the Microsoft Entra device ID, so the rest of the pipeline can use one identity regardless of the certificate's origin.
Figure 1. Tier 1 is tried first. Tier 2 only applies when all of its conditions hold.
Why the OID ending in 25 is the key
For the Intune MDM certificate, look at OID 1.2.840.113556.5.25. This custom extension is the place carrying the machine-readable Entra device identity. The other GUID that catches the eye is not a substitute.
In particular, do not use the Subject CN as the Entra device ID. It is also a GUID, but a different one. I would check that before chasing an authorization failure elsewhere. Two values having the same shape does not make them the same identity, however convincing the spreadsheet looks. The validator comment explains the precedence:
// src/Shared/Security/ClientCertValidator.cs (lines 288-296) // Auto: the operator map wins (explicit intent). For an explicitly // trusted Intune enrollment certificate the .5.25 OID is authoritative // and must precede the Subject CN, because Intune puts a *different* // GUID in the CN that is not the Entra device id. DeviceIdBinding.Auto => LookupByThumbprint(cert) ?? (CanUseIntuneEnrollmentFallback(cert) ? ExtractIntuneEnrollmentDeviceId(cert) : null) ?? FirstSanValue(cert, X509NameType.UrlName) ?? FirstSanValue(cert, X509NameType.DnsName) ?? ExtractFromSubject(cert),
In Auto mode, the PKI certificate can supply its ID through SAN or CN, according to the template. A trusted Intune certificate uses the .25 OID instead. The explicit operator map takes precedence, as the code shows. Once resolved, the identity goes through the same pipeline.
Extracting the Entra device ID from the certificate
The extension contains an ASN.1 OCTET STRING holding the GUID's 16 raw bytes. Parsing is deliberately strict:
// src/Shared/Security/ClientCertValidator.cs (lines 519-538) internal static string? ExtractIntuneEnrollmentDeviceId(X509Certificate2 cert) { var extensions = cert.Extensions .Where(e => e.Oid?.Value == IntuneEnrollmentDeviceIdOid) .ToList(); // Exactly one: duplicated extensions are ambiguous, so fail closed. if (extensions.Count != 1) return null; try { var reader = new AsnReader(extensions[0].RawData, AsnEncodingRules.DER); var bytes = reader.ReadOctetString(); reader.ThrowIfNotEmpty(); return bytes.Length == 16 ? new Guid(bytes).ToString() : null; } catch (AsnContentException) { return null; } }
I would keep all four of these checks when adapting the parser:
- Require one extension. Missing or duplicate extensions both return
null; duplicates are ambiguous, whatever caused them. - Parse strict DER with
AsnReaderand callThrowIfNotEmpty(). Trailing content is a rejection, not something to overlook. - Accept exactly 16 bytes, the GUID size.
- Return
nullfor malformed content rather than letting an exception expose a half-parsed value.
Non-Intune bindings need similar care: the entire CN or SAN must equal a GUID. Do not scan a DN for any GUID-shaped substring. With a permissive template, that could let an attacker bind to a victim's device ID.
None of this parsing establishes trust. Anybody can mint a certificate with that extension. The ID means something only after verifying that the certificate came from an allowed CA. Now we need the chain.
Verifying the certificate with the chain of trust
Each tier builds an X509Chain using its own explicit anchors. The machine's general trust store is not the policy here. I would not want a telemetry endpoint to accept every issuer the operating system happens to trust.
Tier 1, enterprise PKI:
// src/Shared/Security/ClientCertValidator.cs (lines 302-318) private (bool Ok, string? Reason) ValidateEnterpriseTrust(X509Certificate2 cert, DateTime now) { if (_trustedCaThumbprints.Count == 0 && _trustedCaSubjects.Count == 0 && _rootStore.Count == 0) return (false, "enterprise PKI trust anchor not configured"); using var chain = CreateChain(now); if (_rootStore.Count > 0) { chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.AddRange(_rootStore.ToArray()); chain.ChainPolicy.ExtraStore.AddRange(_rootStore.ToArray()); } if (_intermediateStore.Count > 0) chain.ChainPolicy.ExtraStore.AddRange(_intermediateStore.ToArray()); if (!chain.Build(cert)) return (false, FormatChainFailure(chain));
With X509ChainTrustMode.CustomRootTrust, the configured root certificates are the anchors. Building successfully is followed by any optional CA-thumbprint, CA-subject, or separate Root and SubCA constraints. The CA role must have Basic Constraints: CA=true; looking like the named issuer is not sufficient.
Tier 2, Intune MDM:
// src/Shared/Security/ClientCertValidator.cs (lines 405-418) private (bool Ok, string? Reason) ValidateIntuneTrust(X509Certificate2 cert, DateTime now) { using var chain = CreateChain(now); // Some Intune CA chains publish no CRL/OCSP endpoints. Never apply this exception to enterprise PKI. if (_skipIntuneRevocationCheck) chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.AddRange(_intuneRootStore.ToArray()); chain.ChainPolicy.ExtraStore.AddRange(_intuneRootStore.ToArray()); if (_intuneIntermediateStore.Count > 0) chain.ChainPolicy.ExtraStore.AddRange(_intuneIntermediateStore.ToArray()); return chain.Build(cert) ? (true, null) : (false, FormatChainFailure(chain)); }
The Intune tier uses the same chain mechanism but different anchors. Some Intune CA chains have no published CRL/OCSP endpoints. Revocation checking can therefore be disabled for this tier only, with a prominent startup warning. This exception must not be applied to enterprise PKI.
Who decides which tier applies?
The server chooses, not the client. It tries enterprise trust first and reaches the second tier only through its fallback gate:
// src/Shared/Security/ClientCertValidator.cs (lines 249-262) var enterprise = ValidateEnterpriseTrust(cert, utcNow); if (enterprise.Ok) return new CertificateValidationResult(true, cert, TrustTier.EnterprisePki, null); if (CanUseIntuneEnrollmentFallback(cert)) { var intune = ValidateIntuneTrust(cert, utcNow); if (intune.Ok) { _log.LogInformation( "Accepted Intune enrollment certificate (issuer={Issuer}, thumb={Thumb}).", cert.Issuer, cert.Thumbprint); return new CertificateValidationResult(true, cert, TrustTier.IntuneEnrollment, null); }
The fallback gate is intentionally narrow:
// src/Shared/Security/ClientCertValidator.cs (lines 420-441) private bool CanUseIntuneEnrollmentFallback(X509Certificate2 cert) { if (!_allowIntuneEnrollmentCertificateFallback || _intuneRootStore.Count == 0 || (_deviceBinding != DeviceIdBinding.Auto && _deviceBinding != DeviceIdBinding.IntuneEnrollmentOid)) { return false; } if (ExtractIntuneEnrollmentDeviceId(cert) is null) return false; if (!_intuneEnrollmentIssuerSubjects.Contains(cert.Issuer)) { _log.LogWarning( "Certificate carries the Intune enrollment OID but issuer '{Issuer}' is not allow-listed.", cert.Issuer); return false; } return true; }
Before the Intune chain is even considered, all of these must hold:
- An operator has enabled fallback; one setting can disable it again.
- The Intune root anchors are configured.
- Binding is set to
AutoorIntuneEnrollmentOid. - The certificate has the
.25extension. - Its issuer is allow-listed, using the Intune MDM Device CA names by default.
Then, and only then, the validator builds the chain against Intune anchors. An OID with an unrecognized issuer is logged and refused. If enterprise trust already succeeded, this second tier is not involved at all.
From "valid certificate" to "this device may speak"
A valid chain is not permission to report as whichever device the client names. The authenticator separately checks whether this certificate may submit data for the device in the request body:
// src/Shared/Security/TelemetryRequestAuthenticator.cs (lines 135-147) var boundDeviceId = _certValidator.GetBoundDeviceId(certificate); if (string.IsNullOrEmpty(boundDeviceId)) { return _certValidator.RequireDeviceBinding ? (false, 401, "client certificate is missing the configured device-id binding claim", null) : (true, 202, null, null); } if (!Guid.TryParse(claimedEntraDeviceId, out var claimed) || !string.Equals(boundDeviceId, claimed.ToString(), StringComparison.OrdinalIgnoreCase)) { return (false, 403, "client certificate is not bound to the submitted device", boundDeviceId); }
That comparison prevents an authenticated device writing rows as another device. The certificate's identity comes from a CA-asserted value in a verified chain. A device ID typed into JSON cannot override it.
Finally, GraphDeviceAuthorizer uses the frontend's managed identity to ask Microsoft Graph about the device. It must exist and have accountEnabled set to true:
// src/Shared/Security/GraphDeviceAuthorizer.cs (lines 103-110) using var request = new HttpRequestMessage(HttpMethod.Get, $"https://graph.microsoft.com/v1.0/devices(deviceId='{deviceId:D}')?$select=deviceId,accountEnabled"); ... if (response.StatusCode == HttpStatusCode.NotFound) return false; // Outages and missing application consent are not an authorization success. // Propagate these failures so intake fails and the client retains its spool. response.EnsureSuccessStatusCode();
Disabling or deleting a device in Entra therefore stops its telemetry even if the certificate remains cryptographically valid, subject to the positive-answer cache. Failed Graph calls never count as authorization. During an outage the client retains its spool and retries, using the delivery behavior from part 2. It is inconvenient, but accepting a request because the authorization service is unavailable would be the wrong convenience.
The detail that keeps the Intune tier honest
I would review the controls together rather than approve the OID parser on its own:
- The chain establishes issuance by a CA you chose.
- The issuer allow-list restricts tier 2 to Intune device CAs.
- The binding checks the CA-asserted device ID against the body.
- The signature demonstrates possession of the private key.
- Graph checks that the device still exists and is enabled in the tenant.
Each check addresses a different attack. Together they let the extension act as a useful identity claim written by a trusted CA, not as a magic authorization field.
Choosing and operating the tiers
| Situation | What to do |
|---|---|
| You have an enterprise PKI and control the template | Configure the root (and optionally Root/SubCA constraints). Put the device ID in a SAN or CN. |
| You have no PKI, or enrolment is still in progress | Leave the Intune fallback enabled and configure the Intune anchors. |
| You want PKI only | Set ClientCert__AllowIntuneEnrollmentCertificateFallback to false. |
| You have a legacy template without the device ID | Use the operator-maintained thumbprint-to-device map as an escape hatch. |
One documentation detail is easy to miss: Pki* settings are constraints, not trust anchors. Configure the root and make sure the chain still builds. Those constraints do not apply to the Intune tier. Malformed policy values also fail closed; they are not quietly ignored.
Before enabling the fallback
I would verify the anchors for each tier first. Explicit trust (CustomRootTrust) is the foundation; convenience must not silently widen it. Then test the identity path: one extension, strict DER, 16 bytes, followed by certificate-to-request binding and the Entra authorization check.
Also test the unhappy cases. Misconfiguration, malformed policy, and Graph outages must fail closed, not produce an accepted request.
The implementation is in robgrame/LogCollector. Start with src/Shared/Security/ClientCertValidator.cs and its nearby tests for tiers, OID parsing, and PKI role constraints. A lab tenant is a better place to discover that two departments meant different things by “the device GUID.”