DotNetMTlsLayer is a .NET 8 library for mutual TLS between two APIs. It handles both sides of the exchange:
- Kestrel HTTPS configuration for inbound client certificates
- ASP.NET Core certificate authentication and authorization
HttpClientregistration for outbound mTLS calls- shared certificate trust validation logic
- What the library gives you
- Project layout
- Certificate model
- Using the library in your own APIs
- Configuration walkthrough
- Example server setup
- Example client setup
- Self-signed or pinned peers
- Runnable sample APIs
- GitHub CI/CD
- Troubleshooting
- Running the tests
The library does not issue certificates for you. It assumes you already have:
- a server certificate for each API
- a client certificate for each API when it calls the other API
- either a shared/internal CA or explicit thumbprint pinning
What it does provide:
- Kestrel helpers to require or allow client certificates during the TLS handshake
- certificate-auth registration so a trusted client certificate becomes an authenticated ASP.NET Core user
- outbound
HttpClientregistration that automatically sends a client certificate - shared trust evaluation for inbound and outbound peer validation
- certificate loading helpers for
.pfx,.pem, Base64 PFX, and Windows certificate store lookup
src/DotNetMTlsLayer: reusable library codetests/DotNetMTlsLayer.Tests: unit testssamples/ApiA: sample API Asamples/ApiB: sample API Bsamples/certs: generated sample certificatestools/SampleCertificatesGenerator: local certificate generator for the samples
In a typical two-API setup:
- API A hosts HTTPS with its server certificate.
- API B hosts HTTPS with its server certificate.
- API A presents its client certificate when calling API B.
- API B presents its client certificate when calling API A.
- Both APIs trust either:
- the same internal root CA, or
- each other's exact certificate thumbprints.
The library supports both CA-based trust and direct pinning.
sequenceDiagram
participant ApiA as API A
participant ApiB as API B
ApiA->>ApiB: HTTPS request with client certificate
ApiB->>ApiA: Server certificate during TLS handshake
ApiA->>ApiA: Validate API B server certificate
ApiB->>ApiB: Validate API A client certificate
ApiB->>ApiB: Build ClaimsPrincipal from certificate
ApiB-->>ApiA: Protected response
If the library is in the same solution:
dotnet add YourApi.csproj reference src/DotNetMTlsLayer/DotNetMTlsLayer.csprojMost API-to-API mTLS setups need:
- one server certificate for the current API
- one client certificate for outbound calls from the current API
- one trusted root certificate, or explicit thumbprints/subject names for peers
Example:
var serverCertificate = CertificateLoader.LoadFromPfxFile("certs/api-a-server.pfx", "password");
var clientCertificate = CertificateLoader.LoadFromPfxFile("certs/api-a-client.pfx", "password");
var trustedRoot = CertificateLoader.LoadPublicCertificate("certs/internal-root-ca.cer");Use UseMutualTls on the listener. This configures HTTPS and validates incoming client certificates.
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ListenAnyIP(5001, listen =>
{
listen.UseMutualTls(options =>
{
options.ServerCertificate = serverCertificate;
options.ClientCertificateTrust.TrustedRoots.Add(trustedRoot);
options.ClientCertificateTrust.AllowSubjectName("api-b-client");
});
});
});This turns a validated client certificate into an authenticated user that can be used by authorization.
builder.Services.AddMutualTlsAuthentication(options =>
{
options.ClientCertificateTrust.TrustedRoots.Add(trustedRoot);
options.ClientCertificateTrust.AllowSubjectName("api-b-client");
});
builder.Services.AddAuthorization();The library creates a default policy named MutualTlsDefaults.AuthorizationPolicyName.
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/secure/data", () => Results.Ok("secure"))
.RequireAuthorization(MutualTlsDefaults.AuthorizationPolicyName);Use AddMutualTlsHttpClient so the outgoing HttpClient sends your client certificate and validates the peer server certificate.
builder.Services.AddMutualTlsHttpClient("ApiB", options =>
{
options.BaseAddress = new Uri("https://api-b.internal:5002");
options.ClientCertificate = clientCertificate;
options.ServerCertificateTrust.TrustedRoots.Add(trustedRoot);
options.ServerCertificateTrust.AllowSubjectName("api-b-server");
});app.MapGet("/call-peer", async (IHttpClientFactory httpClientFactory) =>
{
var client = httpClientFactory.CreateClient("ApiB");
var response = await client.GetStringAsync("/secure/data");
return Results.Ok(response);
});These are the main types you will use:
CertificateLoaderLoads certificates from.pfx,.pem, Base64 PFX data, or the Windows certificate store.MutualTlsTrustOptionsDefines what you trust:TrustedRootsIntermediateCertificates- allowed thumbprints
- allowed subject names
- revocation and chain policy options
MutualTlsServerTlsOptionsControls the HTTPS listener:ServerCertificateClientCertificateMode- inbound trust rules
MutualTlsServerAuthenticationOptionsControls certificate authentication and the default authorization policy.MutualTlsHttpClientOptionsControls outbound mTLS:BaseAddressClientCertificate- peer server trust options
Common trust setups:
- Internal CA:
Add the CA certificate to
TrustedRootsand optionally restrict accepted peers by subject name. - Direct pinning:
Call
AllowThumbprint(...)and setAllowThumbprintPinningWithoutChainValidation = true. - Hybrid: Trust a CA but still pin a subject or thumbprint for a tighter allow-list.
using DotNetMTlsLayer;
var builder = WebApplication.CreateBuilder(args);
var serverCertificate = CertificateLoader.LoadFromPfxFile("certs/api-a-server.pfx", "password");
var trustedClientRoot = CertificateLoader.LoadPublicCertificate("certs/internal-root-ca.cer");
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ListenAnyIP(5001, listen =>
{
listen.UseMutualTls(options =>
{
options.ServerCertificate = serverCertificate;
options.ClientCertificateTrust.TrustedRoots.Add(trustedClientRoot);
});
});
});
builder.Services.AddMutualTlsAuthentication(options =>
{
options.ClientCertificateTrust.TrustedRoots.Add(trustedClientRoot);
options.ClientCertificateTrust.AllowSubjectName("api-b-client");
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/secure", () => Results.Ok("mTLS OK"))
.RequireAuthorization(MutualTlsDefaults.AuthorizationPolicyName);
app.Run();using DotNetMTlsLayer;
var builder = WebApplication.CreateBuilder(args);
var clientCertificate = CertificateLoader.LoadFromPfxFile("certs/api-b-client.pfx", "password");
var trustedServerRoot = CertificateLoader.LoadPublicCertificate("certs/internal-root-ca.cer");
builder.Services.AddMutualTlsHttpClient("ApiA", options =>
{
options.BaseAddress = new Uri("https://api-a.internal:5001");
options.ClientCertificate = clientCertificate;
options.ServerCertificateTrust.TrustedRoots.Add(trustedServerRoot);
options.ServerCertificateTrust.AllowSubjectName("api-a-server");
});If both APIs pin each other by thumbprint instead of using a CA, enable thumbprint pinning explicitly:
options.ServerCertificateTrust.AllowThumbprint("7A3C...");
options.ServerCertificateTrust.AllowThumbprintPinningWithoutChainValidation = true;The same option exists on ClientCertificateTrust for the server side. Thumbprint pinning works, but it shifts trust management to certificate distribution and rotation.
The repo includes two runnable sample APIs in samples/ApiA and samples/ApiB, plus a certificate generator in tools/SampleCertificatesGenerator.
dotnet run --project tools/SampleCertificatesGeneratorThis creates:
samples/certs/mtls-root.cersamples/certs/api-a-server.pfxsamples/certs/api-a-client.pfxsamples/certs/api-b-server.pfxsamples/certs/api-b-client.pfxsamples/certs/operator-client.pfx
All generated PFX files use the password sample-password.
Open two terminals from the repository root:
dotnet run --project samples/ApiAdotnet run --project samples/ApiBAPI addresses:
ApiA:https://localhost:5001ApiB:https://localhost:5002
Once both APIs are running, you can trigger an API-to-API mTLS call without presenting a browser certificate yourself:
curl -k https://localhost:5001/demo/call-peer
curl -k https://localhost:5002/demo/call-peerEach API will:
- create an outbound
HttpClientconfigured with its own client certificate - call the peer API's
/secure/whoamiendpoint - validate the peer server certificate against the generated root CA
- present its client certificate to the peer
- return the authenticated identity details from the peer
Use the generated operator certificate to call /secure/whoami yourself.
PowerShell example:
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new(
"samples/certs/operator-client.pfx",
"sample-password")
Invoke-RestMethod `
-Uri "https://localhost:5001/secure/whoami" `
-Certificate $cert `
-SkipCertificateCheckWithout a client certificate, the /secure/* endpoints should return unauthorized or forbidden depending on the request path and auth state.
More sample-specific detail is in samples/README.md.
The repository now includes two GitHub Actions workflows:
ci.yml behavior:
- runs on every push
- runs on every pull request
- can also be started manually
- restores, builds, and runs the solution tests
release.yml behavior:
- runs when a git tag matching
v*is pushed - can also be started manually with:
versionpublish_to_nuget
- restores, builds, and tests the solution
- packs
src/DotNetMTlsLayer - uploads the
.nupkgand.snupkgas workflow artifacts - creates a GitHub release
- optionally pushes the package and symbol package to NuGet.org
Release versioning:
- tag-driven releases use the tag version, for example
v1.2.3becomes package version1.2.3 - manual releases use the
versioninput exactly as provided
Required GitHub repository secret:
NUGET_API_KEY: NuGet.org API key with push permission for the package ID
The release workflow automatically injects the active GitHub repository URL into the package metadata during CI publishing.
If you also plan to pack locally outside GitHub Actions, replace these placeholder values in src/DotNetMTlsLayer/DotNetMTlsLayer.csproj:
RepositoryUrlPackageProjectUrl
Example release commands:
git tag v1.0.0
git push origin v1.0.0Or run release.yml manually from GitHub Actions and provide the version in the workflow form.
The certificate did not match any configured thumbprint or subject name.Your allow-list does not match the presented certificate. Check the configured subject names or thumbprints.TLS policy errors: RemoteCertificateNameMismatchThe hostname you are calling does not match the server certificate. Fix DNS/URL usage or issue the server certificate with the correct subject alternative name.UntrustedRootThe presented certificate chain does not build to a trusted root. Add the root CA toTrustedRootsor switch to explicit pinning.- Protected endpoints return
401or403Make sure the client presented a certificate and thatUseAuthentication()andUseAuthorization()are in the middleware pipeline. - Sample APIs fail at startup because certificate files are missing
Run
dotnet run --project tools/SampleCertificatesGeneratorfirst.
dotnet test DotNetMTlsLayer.sln