dotnet / dotnet/aspnetcore

ASP.NET Certificate Authentication Fails with PartialChain When Intermediate Is Included in Client Certificate

Open
#61,900 14 comments 0 reactions 0 assignees View on GitHub
area-auth investigate
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 10h
Merged PRs (30d)
281

Description

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Describe the bug

🔐 Certificate Structure:
I’m using a custom certificate chain with the following hierarchy:

```
Root CA
└── Intermediate CA
└── End-Entity (Leaf) Certificate
```

The Root CA signs the Intermediate CA.

The Intermediate CA signs the End-Entity (client/leaf) certificate.

The server trusts only the Root CA. The Intermediate CA is not installed on the server's trusted certificate store.

The client certificate includes both the Intermediate and End-Entity certificates in a full chain (e.g., PFX or PEM bundle).

**Problem:**
**When the client sends the certificate with both the Intermediate and End-Entity certificates:**

**The ASP.NET Certificate Authentication Middleware fails with this error:**
**PartialChain: unable to get local issuer certificate**
**The middleware is unable to build a full trust chain, even though the intermediate is present in the client certificate chain.**

If only the Intermediate certificate is sent by the client, the request is correctly success

Expected Behavior:
The middleware should be able to build the chain from the leaf through the intermediate up to the trusted root, using the intermediate provided by the client — as most TLS clients (e.g., OpenSSL, browsers) do.

Server side code

```
public static AuthenticationBuilder AddSaleSwiftCertificate(this AuthenticationBuilder builder)
{
return builder.AddCertificate(options =>
{
X509Certificate2 rootCertificate = GetRootCertificate();

options.RevocationMode = X509RevocationMode.NoCheck;
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
options.CustomTrustStore = [rootCertificate];

options.Events = new CertificateAuthenticationEvents()
{
OnCertificateValidated = context =>
{
IEnumerable claims = ParseSaleSwiftCertificateToClaims(context);

ClaimsIdentity claimsIdentity = new(claims, context.Scheme.Name);

if (context.Principal == null)
{
context.Principal = new ClaimsPrincipal(claimsIdentity);
}
else
{
context.Principal.AddIdentity(claimsIdentity);
}

context.Success();

return Task.CompletedTask;
}
};
})
.AddCertificateCache(options =>
{
options.CacheSize = 1024;
options.CacheEntryExpiration = TimeSpan.FromMinutes(2);
});
}
```

Client side code (HttpClient that communicate with the api)

```
services.AddHttpClient(httpClient =>
{
httpClient.BaseAddress = new Uri(configuration.GetConnectionString("PossApi")!);
}).ConfigurePrimaryHttpMessageHandler((serviceProvider) =>
{
HttpClientHandler httpClientHandler = new();
httpClientHandler.ClientCertificateOptions = ClientCertificateOption.Manual;
PosServerCertificate posServerCertificate = serviceProvider.GetRequiredService();
httpClientHandler.ClientCertificates.Add(posServerCertificate.Value);

return httpClientHandler;
});
```

Question:
Is this behavior expected in ASP.NET Core?

Is it correct that the server trusts only the root CA, while the client provides both the intermediate and leaf certificates in its chain?
Or am I missing a required step (e.g., should the server also know about the intermediate)?

Certificates are added to files

**PosServer is the leaf**
**PosCa is the intermidate**
**SaleSwiftCA is the root**

**All passwords 123456**

[Certificates.zip](https://github.com/user-attachments/files/20189664/Certificates.zip)

### Expected Behavior

_No response_

### Steps To Reproduce

_No response_

### Exceptions (if any)

_No response_

### .NET Version

9

### Anything else?

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.