diff --git a/docs/backend/client-cert-tenant-binding.md b/docs/backend/client-cert-tenant-binding.md
index d350fad4..85995df7 100644
--- a/docs/backend/client-cert-tenant-binding.md
+++ b/docs/backend/client-cert-tenant-binding.md
@@ -10,7 +10,7 @@ tags:
- certificates
- intune
- multi-tenant
-timestamp: 2026-08-24T00:00:00+02:00
+timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
@@ -129,6 +129,41 @@ Grep marker for every site involved: `CERT-TENANT-BINDING`.
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
+# Certificate transport and proof of possession
+
+Every stage above validates certificate *content*: chain, validity window, EKU, tenant extension.
+None of them proves the caller holds the private key — a certificate is public material (sent in
+clear in a TLS 1.2 client-auth handshake, readable from the machine store by any local user, present
+on resold hardware). Proof of possession comes from the TLS handshake alone: the platform runs
+`clientCertMode=Required` on every path not listed in its `clientCertExclusionPaths`, forwards the
+handshake certificate as `X-ARR-ClientCert`, and **strips any inbound copy of that header — on
+excluded paths too** (verified 2026-08-29 with a spoofed header against `/api/bootstrap/config`:
+the validator saw "No certificate provided"). So `X-ARR-ClientCert` is trustworthy exactly because
+the caller cannot write it.
+
+Two consequences shape `SecurityValidator`:
+
+* **No other certificate header is ever read.** A former local-dev fallback (`X-Client-Certificate`)
+ accepted certificate bytes from a client-writable header, which decouples the certificate from
+ key possession: anyone holding a device's public certificate would authenticate as that device.
+ Removed 2026-08-29. Local development sets `X-ARR-ClientCert` directly — Core Tools does not strip
+ it.
+* **Bootstrap routes are token-only, fail-closed.** `/api/bootstrap/*` is in the platform's
+ exclusion list so cert-less OOBE agents can reach it — which also means nothing on those paths
+ proves a handshake happened. The token is therefore the sole credential there:
+ `SecurityValidator.IsBootstrapRoute` (path prefix, mirroring the exclusion entry so no function
+ can forget to opt in) rejects a missing or **empty** `X-Bootstrap-Token`, and a missing
+ `BootstrapSessionService`, with 401 before the certificate stage can run. The live gap this
+ closes: the function-level `Headers.Contains` gate accepted an empty header while the validator
+ treated it as "no token" and fell through to the certificate stage, where the fallback header
+ above was parsed. Bootstrap tokens are never accepted as a *replacement* for the certificate on
+ platform-enforced routes either — there the cert requirement is already satisfied by the
+ handshake before the request reaches code.
+
+The exclusion list is portal-only state (no IaC). Adding `/api/agent` to it, or moving a
+device-authenticated function under an excluded prefix, would silently reopen the header path;
+`BootstrapRouteFailClosedTests` pins the code half of this contract.
+
**Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was
issued to this tenant; it does not prove the certificate belongs to a device the tenant still has.
`IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the
diff --git a/docs/index.md b/docs/index.md
index 6e36f426..89602aab 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -80,7 +80,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
-* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
+* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections.
diff --git a/docs/log.md b/docs/log.md
index dbff4c35..5519a1e5 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,5 +1,9 @@
# Log
+## 2026-08-29 (7)
+
+* **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage.
+
## 2026-08-29 (6)
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs
index ba615daa..5fe26aa2 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs
@@ -1,4 +1,4 @@
-using System.Net;
+using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
@@ -99,8 +99,9 @@ public sealed class BootstrapAuthDisabledTenantTests
{
// Sanity: a non-disabled tenant DOES reach the bootstrap branch (which will then
// run its own validation against _bootstrapSessionService). With _bootstrapSessionService
- // null in this test, the bootstrap branch falls through to the cert path — proving
- // that the §0 gate did NOT block.
+ // null and no request URL (= not a /api/bootstrap/* route, where that combination is
+ // rejected fail-closed — see BootstrapRouteFailClosedTests), the branch falls through to
+ // the platform-cert path — proving that the §0 gate did NOT block.
var enabled = TenantConfiguration.CreateDefault(TenantId);
enabled.Disabled = false;
enabled.ValidateAutopilotDevice = false;
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapRouteFailClosedTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapRouteFailClosedTests.cs
new file mode 100644
index 00000000..63e539bd
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapRouteFailClosedTests.cs
@@ -0,0 +1,161 @@
+using System.Net;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Bootstrap routes (/api/bootstrap/*) are excluded from the platform's TLS client-cert
+/// requirement, so nothing on those paths proves an mTLS handshake ever happened. The bootstrap
+/// token must therefore be the ONLY credential accepted there — a request that reaches the
+/// certificate stage on a bootstrap route would authenticate from header bytes alone (a public
+/// certificate is replayable without its private key). Live probe 2026-08-29: an empty
+/// X-Bootstrap-Token passed the function gate, skipped the token branch and had a
+/// client-supplied certificate header parsed. These tests pin the fail-closed behaviour.
+///
+public sealed class BootstrapRouteFailClosedTests
+{
+ private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
+ private const string BootstrapRoute = "https://example.invalid/api/bootstrap/config";
+ private const string AgentRoute = "https://example.invalid/api/agent/telemetry";
+
+ // A well-formed DER certificate is not needed: what matters is whether the certificate stage
+ // ran at all, and the "No certificate provided" vs. parse-error details tell the two apart.
+ private const string JunkCertBase64 = "QUJD";
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task BootstrapRoute_TokenMissingOrEmpty_Rejected401_BeforeCertificateStage(string? token)
+ {
+ var sut = BuildValidator(bootstrapService: null);
+ var req = BuildRequest(BootstrapRoute, token, ("X-ARR-ClientCert", JunkCertBase64));
+
+ var result = await sut.ValidateRequestAsync(req, TenantId);
+
+ Assert.False(result.IsValid);
+ Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
+ Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task BootstrapRoute_ClientSuppliedCertificateHeader_NeverParsed()
+ {
+ var sut = BuildValidator(bootstrapService: null);
+ var req = BuildRequest(BootstrapRoute, token: "", ("X-Client-Certificate", JunkCertBase64));
+
+ var result = await sut.ValidateRequestAsync(req, TenantId);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
+ Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage);
+ Assert.DoesNotContain("certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task AgentRoute_ClientCertificateHeader_IsIgnored()
+ {
+ // The removed X-Client-Certificate fallback: on a platform-enforced route the only
+ // certificate source is X-ARR-ClientCert. A client-writable header must read as "no cert".
+ var sut = BuildValidator(bootstrapService: null);
+ var req = BuildRequest(AgentRoute, token: null, ("X-Client-Certificate", JunkCertBase64));
+
+ var result = await sut.ValidateRequestAsync(req, TenantId);
+
+ Assert.False(result.IsValid);
+ Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
+ Assert.Equal("Invalid or missing client certificate", result.ErrorMessage);
+ Assert.Equal("No certificate provided", result.Details);
+ }
+
+ [Fact]
+ public async Task AgentRoute_ArrClientCertHeader_ReachesCertificateStage()
+ {
+ // Control: the platform-set header is still the one consulted (junk bytes → parse error,
+ // proving the certificate stage ran on the supplied value).
+ var sut = BuildValidator(bootstrapService: null);
+ var req = BuildRequest(AgentRoute, token: null, ("X-ARR-ClientCert", JunkCertBase64));
+
+ var result = await sut.ValidateRequestAsync(req, TenantId);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
+ Assert.Equal("Invalid or missing client certificate", result.ErrorMessage);
+ Assert.NotEqual("No certificate provided", result.Details);
+ }
+
+ [Fact]
+ public void GetBootstrapToken_TreatsEmptyAsAbsent()
+ {
+ Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "")));
+ Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: null)));
+ Assert.Equal("abc", SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "abc")));
+ }
+
+ [Theory]
+ [InlineData("https://example.invalid/api/bootstrap/config", true)]
+ [InlineData("https://example.invalid/API/Bootstrap/register-session", true)]
+ [InlineData("https://example.invalid/api/bootstrapx/config", false)]
+ [InlineData("https://example.invalid/api/agent/telemetry", false)]
+ public void IsBootstrapRoute_MatchesPlatformExclusionPrefix(string url, bool expected)
+ {
+ Assert.Equal(expected, SecurityValidator.IsBootstrapRoute(BuildRequest(url, token: null)));
+ }
+
+ // ---------------------------------------------------------------- harness
+
+ private static SecurityValidator BuildValidator(BootstrapSessionService? bootstrapService)
+ {
+ var configRepo = Mock.Of();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ // AllowInsecureAgentRequests clears the "at least one device validator" gate so agent-route
+ // requests reach the certificate stage — the stage these tests reason about.
+ var config = TenantConfiguration.CreateDefault(TenantId);
+ config.AllowInsecureAgentRequests = true;
+
+ var configServiceMock = new Mock(
+ configRepo, Mock.Of>(), cache)
+ { CallBase = false };
+ configServiceMock
+ .Setup(x => x.TryGetConfigurationAsync(It.IsAny()))
+ .ReturnsAsync((config, true));
+
+ var adminConfigServiceMock = new Mock(
+ configRepo, Mock.Of>(), cache)
+ { CallBase = false };
+ adminConfigServiceMock
+ .Setup(x => x.GetConfigurationAsync())
+ .ReturnsAsync(new AdminConfiguration());
+
+ return new SecurityValidator(
+ configService: configServiceMock.Object,
+ adminConfigService: adminConfigServiceMock.Object,
+ rateLimitService: new RateLimitService(cache, Mock.Of>()),
+ autopilotDeviceValidator: null!,
+ corporateIdentifierValidator: null!,
+ logger: Mock.Of(),
+ bootstrapSessionService: bootstrapService,
+ deviceAssociationValidator: null);
+ }
+
+ private static HttpRequestData BuildRequest(string url, string? token, params (string Name, string Value)[] extraHeaders)
+ {
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.Items).Returns(new Dictionary