Series · LogCollector: authenticated telemetry for Intune devices · Part 1 of 3

Intune · Azure Monitor · Log Analytics · mTLS

LogCollector, part 1: replacing workspace keys

Why the retired Data Collector API needed more than a new URL, and how device certificates, signed requests, replay checks and managed identity replace shared workspace keys.

Drafted
Published
Updated
Reading time
9 min
Author
Roberto Gramellini

Source: LogCollector · revision 680d7bdf

  • Intune telemetry
  • Log Analytics
  • HTTP Data Collector API retirement
  • Logs Ingestion API
  • mTLS
  • client certificate
  • anti-replay
  • Data Collection Rule
  • Azure Functions
  • Service Bus
  • Bicep
  • PowerShell
A laptop sending signed data packets toward a hexagonal cloud node.

A script collects useful device data. Sending it to Log Analytics looks simple until you ask where the workspace key will live. In the usual endpoint script, the answer is on every laptop receiving that script. One extracted copy gives somebody else the same ability to write forged data into the workspace.

LogCollector collects telemetry from Windows devices through Intune and sends it to Azure Monitor without distributing workspace or function keys. Each device proves its identity on every request using a certificate and signature. I would rather manage that per-device trust than explain why one shared credential needed to be copied a few thousand times.

The immediate reason for the change was the HTTP Data Collector API's retirement in September 2026. The workspace-key model behind that old custom-ingestion path was already a liability. Migration was a chance to remove it, not just translate the request.

This is part 1 of 3, focused on the cloud pipeline and its request checks. Part 3 examines certificate trust in more detail. For the device's offline spool and Intune packaging, there is Part 2.

Why we built it

The old HTTP Data Collector API recipe was short: build JSON, use the workspace ID and workspace primary key to sign the request, then POST it to the workspace. It fitted in a small PowerShell script. That simplicity explains why it became common in Intune scripts and inventory solutions, including legacy deployments that now need migration.

Two problems made keeping that recipe untenable:

  1. Retirement was a real deadline. The deprecated HTTP Data Collector API stopped working in September 2026. Moving to the Logs Ingestion API, with Data Collection Endpoints and Rules, changes authentication, payload contracts, and table declarations. Replacing the URL is not a migration.
  2. The key is shared workspace-wide. Distribute it in a script or Intune package and each device holds the same write secret. Extraction from one laptop enables forged submissions. Rotation then becomes a fleet-wide update, not a change for just that device.

The Logs Ingestion API supports Microsoft Entra authentication and RBAC, but an endpoint cannot safely hold an Entra application secret either. Putting that secret in the Intune script would only rename the problem. The device credential needs to be device-specific, not a shared means of impersonating the service.

LogCollector therefore changes the design as well as the API. Devices use the certificate they already have, per-device, non-exportable, and individually revocable. A thin cloud frontend validates each request. Only the server-side managed identity calls the Logs Ingestion API. The producer can still run a script and submit data, while identity checks, replay protection, and least-privilege roles sit in the shared pipeline.

Why not "just use the workspace key"?

Beyond retirement, there is the question of what comes after inventory. Health checks and remediation results need delivery too. I would not give every new producer its own authentication design. The common pipeline needs to cope with:

  • Devices that spend much of their time offline, particularly laptops.
  • Large payloads that exceed queue-message capacity.
  • Establishing which device sent the data, while rejecting impostors and replays.
  • New telemetry types that should not each require a new service.

The architecture, front to back

End-to-end architecture from the Windows device through the frontend, blob and Service Bus, to the worker, DCE and Log Analytics.

Figure 1. Two compute shapes on purpose: App Service for mTLS at the edge, Flex Consumption for scale-to-zero processing.

The device's client module signs and submits the telemetry to a Linux App Service, using a client certificate over TLS 1.2+. After validation, the frontend stores the payload in Blob Storage and puts only a pointer on Service Bus.

A .NET 10 isolated Function processes that pointer. It verifies SHA-256, adds server-asserted identity columns, chunks the rows, and publishes through the Logs Ingestion API with a Data Collection Endpoint and direct Data Collection Rule. The queue does not have to carry the inventory document itself.

Managed identity and RBAC cover service-to-service access, with storage key authentication switched off. There is no replacement shared key hidden in that part of the path.

Six gates before a byte is accepted

A pipeline of six validation gates: TLS client certificate, chain re-validation, body signature, anti-replay reservation, device identity, table allow-list.

Figure 2. Each gate rejects a different kind of liar.

Signed, every time

For IDA-SIGNATURE-V1, the client signs a canonical string containing the method, path, timestamp, fresh nonce, and exact body bytes. The signing key belongs to the device certificate:

# src/Client/RequestSigning.psm1 (lines 95-110)
$timestamp = [DateTimeOffset]::UtcNow
$nonce = [Guid]::NewGuid()
$bodyBytes = $script:Utf8NoBom.GetBytes($Body)

$canonical = Get-SignedRequestCanonicalText `
    -Method $Method `
    -Path $Uri.AbsolutePath `
    -Timestamp $timestamp `
    -Nonce $nonce `
    -BodyBytes $bodyBytes

$canonicalBytes = $script:Utf8NoBom.GetBytes($canonical)

$algorithm = $null
$signature = $null

Certificate first, signature second

The frontend checks the certificate chain, validity window, and EKU before verifying the application signature over the received bytes. I would keep this order: first establish that the certificate is acceptable, then spend effort proving that its key signed this body.

// src/Shared/Security/TelemetryRequestAuthenticator.cs (lines 81-96)
// 2) Client certificate: chain, validity window, EKU, trust tier.
var certResult = _certValidator.Validate(context.ConnectionCertificate, context.ForwardedCertificateHeader);
if (!certResult.Ok)
    return Deny(401, $"client cert: {certResult.Reason}", certResult.Certificate);

var cert = certResult.Certificate;
if (cert is null)
{
    // Only reachable when RequireClientCert is off; signatures still need a
    // public key, so there is nothing left to verify against.
    return Deny(401, "client certificate is required for signed requests");
}

// 3) Body signature over the exact received bytes.
var signature = _signatureVerifier.Verify(

Replays die in Table Storage

A captured request can still have a valid signature when somebody sends it again. Replay protection atomically reserves each (certificate, nonce) pair in Table Storage; a second reservation fails. The stored identity uses a hash, never the raw thumbprint:

// src/Shared/Security/AzureTableReplayNonceStore.cs (lines 39-54)
// boundary. The row key hashes the thumbprint so the raw certificate
// identity is never stored, while remaining certificate-scoped.
var normalizedThumbprint = NormalizeThumbprint(certificateThumbprint);
var rowMaterial = Encoding.UTF8.GetBytes($"{normalizedThumbprint}:{nonce:N}");
var rowKey = Convert.ToHexString(SHA256.HashData(rowMaterial)).ToLowerInvariant();
var partitionKey = $"{requestTimestamp.UtcDateTime:yyyyMMddHH}-{rowKey[0]}";

var entity = new TableEntity(partitionKey, rowKey)
{
    ["RequestTimestamp"] = requestTimestamp,
    ["ExpiresAt"] = expiresAt,
    ["CertificateThumbprintHash"] = Convert.ToHexString(
        SHA256.HashData(Encoding.UTF8.GetBytes(normalizedThumbprint))).ToLowerInvariant(),
};

return await AddEntityAsync(entity, retryAfterMissingTable: true, ct).ConfigureAwait(false);

Authenticated is not the same as authorised

The device does not get to pick any destination it likes. A valid request can target only a table explicitly mapped by an operator to a DCR stream:

// src/Functions/Frontend/Functions/TelemetryIngestFunction.cs (lines 150-160)
// Destination allow-list: a client may only target a table that an
// operator has explicitly mapped to a DCR stream.
if (!_streamMap.IsAllowed(envelope.TableName))
{
    _log.LogWarning("Submission targeted unmapped table {Table}.", envelope.TableName);
    return Problem(StatusCodes.Status400BadRequest,
        $"table '{envelope.TableName}' is not an accepted ingestion target", correlationId);
}

var binding = _authenticator.AuthorizeDeviceBinding(auth.Certificate!, envelope.EntraDeviceId);
if (!binding.Ok)

Immediately after that allow-list check comes device binding. The certificate must identify the Entra device ID claimed by the envelope. Trusting the certificate is not permission to report as another device.

Behind the edge: verify, chunk, publish

The worker resolves payloads only within its configured blob container. A missing blob is a permanent failure: repeated attempts cannot retrieve something already reclaimed. For a blob that exists, the worker compares its digest with the value recorded at intake, using constant-time comparison:

// src/Functions/Worker/Services/PayloadBlobReader.cs (lines 66-78)
if (string.IsNullOrWhiteSpace(pointer.PayloadSha256))
    return Permanent("payload digest is required");

var actual = Convert.ToBase64String(SHA256.HashData(content));
if (!CryptographicOperations.FixedTimeEquals(
        System.Text.Encoding.ASCII.GetBytes(actual),
        System.Text.Encoding.ASCII.GetBytes(pointer.PayloadSha256)))
{
    _log.LogError(
        "Payload digest mismatch for correlationId={CorrelationId} blob={Blob}",
        pointer.CorrelationId, pointer.BlobName);
    return Permanent("payload digest does not match the value recorded at intake");
}

Publishing chunks are 850 KB or less, with configuration clamped to that hard limit. The publisher respects the service's Retry-After. It also detects oversize rows before sending any bytes, avoiding a submission partly sent before discovering one row cannot fit.

Permission to publish, and nothing more

The worker gets Monitoring Metrics Publisher on the DCR, rather than the workspace. That scope limits its writes to the streams declared by that rule. This is a permission worth checking in the deployed role assignment, not only in the design:

// Artifacts/MSLabs/1.15.2/1-Azure/infra/main.bicep (lines 1038-1047)
// Monitoring Metrics Publisher on the DCR is exactly the permission the Logs
// Ingestion API checks. It is scoped to this DCR, not the workspace, so the
// worker can only write the streams declared above.
resource raWorkerDcr 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(dcr.id, workerIdentity.id, 'metrics-publisher')
  scope: dcr
  properties: {
    roleDefinitionId: roleMonitoringMetricsPublisher
    principalId: workerIdentity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

Schema as configuration

For another telemetry purpose, add a table definition and stream mapping instead of another service code path. The table module loops over the definitions:

// .../infra/modules/log-analytics-tables.bicep (lines 6-20)
@description('Name of the Log Analytics workspace (existing or just-created) that will receive the tables.')
param workspaceName string

@description('Table definitions: array of { name, columns }.')
param tableDefinitions array

@description('Retention (days) applied to each table.')
param retentionInDays int

resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
  name: workspaceName
}

resource tables 'Microsoft.OperationalInsights/workspaces/tables@2023-09-01' = [for table in tableDefinitions: {
  parent: workspace
  name: table.name

Constraints to check before deployment

  • Flex Consumption cannot do client-certificate ingress. The frontend and worker deliberately use different hosting.
  • A 256 KB queue message will not hold large payloads. Store them in blobs and queue pointers.
  • Mandatory client certificates affect Kudu/SCM too. The deployment script temporarily adjusts access restrictions while preserving a fail-closed posture.
  • Storage and Function names must be globally unique. Choose the deployment prefix once and carefully, before it becomes a naming committee's recurring agenda item.
  • Device validation requires Graph permission (Device.Read.All). It can be disabled, but tenant-membership validation is then weaker, as documented.

What I would verify first

Start with the request checks. The certificate, signed body, and replay reservation work together; none alone gives the same guarantee. Keep the cheapest decisive gates early, then check the worker's boundary as well: blobs carry payloads, queues carry pointers, and publishing permission is scoped to the DCR rather than the workspace.

That covers acceptance and publishing, not the hours when a laptop has no connection. Part 2 follows the local spool and the packaging needed to make delivery work outside the cloud diagram.