Client Secrets, Mutual TLS and Private Key JWT, Oh My!
Summary: This guide compares three methods for authenticating confidential OAuth and OpenID Connect clients in ASP.NET Core: Client Secrets, Private Key JWT, and Mutual TLS.
• Client Secrets: The standard, easy-to-implement shared secret method ideal for basic deployments.
• Private Key JWT: An asymmetric, secure alternative that offers improved security without requiring infrastructure changes.
• Mutual TLS: Provides the highest level of assurance using client certificates, though it adds significant operational complexity.
Our Recommendation: Start with Client Secrets for simple setups, upgrade to Private Key JWT when you need stronger security without overhead, and reserve mTLS for FAPI compliance or high-assurance environments.
When you configure a confidential OAuth or OpenID Connect client, that client needs to authenticate itself against the authorization server. Most samples and demos out there demonstrate using a client secret for authentication, because this is the most convenient way to achieve a working sample.
Client secrets work just fine, but there are other more secure ways for clients to authenticate: mutual TLS (mTLS) and private key JWT. The main difference between these three methods is that client secrets use a shared secret: the same secret value needs to be known by both the client and the authorization server. On the other hand, mTLS and private key JWT are asymmetric authentication mechanisms: the client holds the private key, and the authorization server only needs to know about the public key.
Let's see what is needed to use each of these client authentication mechanisms in ASP.NET Core web clients.
Adding Client Secret Authentication to an Application
Using client secrets to achieve client authentication is by far the most straightforward method to implement: ASP.NET Core has built-in support for client secret authentication when configuring OpenID Connect.
Begin by configuring the client in Duende IdentityServer and adding a client secret:
Csharp
isBuilder.AddInMemoryClients([
new Client() {
ClientId = "confidential.client",
ClientSecrets =
[
new Secret("your-shared-secret-goes-here".Sha256())
],
// ...
}
]); The .Sha256() extension method at the end of the secret value will produce a SHA-256 hash version of the client secret. By default, Duende IdentityServer expects client secrets to be hashed. When a client authenticates, it needs to use the plain-text secret value instead (ie "your-shared-secret-goes-here").
Duende IdentityServer also supports SHA-512 when storing a shared secret, using the .Sha512() extension method.
⚠️ Caution - You can also opt in to use plain-text shared secrets in IdentityServer, although we highly recommend not doing so. If you do need to support plain-text shared secrets, you'll have to manually register the PlainTextSharedSecretValidator type when configuring IdentityServer: Csharp
builder.Services.AddIdentityServer()
// Only use this if you know what you're doing!
.AddSecretValidator<PlainTextSharedSecretValidator>();Next, in the ASP.NET Core Web client application, configure the OpenID Connect authentication handler using the same client ID and client secret value:
Csharp
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookies", options =>
{
options.Cookie.Name = ".Web.Auth";
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://demo.duendesoftware.com";
options.ClientId = "confidential.client";
options.ClientSecret = "your-shared-secret-goes-here";
// ...
});That's all there is to it! You now have client secret authentication configured and ready to use.
When you wish to rotate the client secret, you can add a new secret to the client definition in IdentityServer while setting an expiration date on the old secret. Then set the new client secret in the client application, and roll out the change after deploying the updated IdentityServer configuration.
Adding Private Key JWT Authentication to an Application
Private key JWT authentication uses asymmetric cryptography: the client holds a private key that it uses to create and sign a JWT, and the authorization server only needs to know about the corresponding public key. This means that, unlike shared secrets, the secret material never leaves the client and is never transmitted over the network.
Setting Up Private Key JWT Secrets in IdentityServer
First, enable the JWT bearer client authentication secret parser and validator:
Csharp
var isBuilder = builder.Services.AddIdentityServer();
isBuilder.AddJwtBearerClientAuthentication();Next, configure the client's definition with a secret that contains the public key part of the public/private key pair used by the client when it generates the private key JWT client assertion. This secret can be defined as a base64-encoded X.509 certificate or a JSON Web Key. You can load the secret from a certificate store, from disk, a database or a secure environment such as Azure KeyVault.
In theory, you can use the entire certificate or JSON Web Key when configuring the client secret, containing both the public and private parts of the certificate or JSON Web Key. However, since IdentityServer only requires the public key to verify the client assertion, it is a best practice to only provide the public key part.
Csharp
isBuilder.AddInMemoryClients([
new Client() {
ClientId = "confidential.client",
ClientSecrets =
[
new Secret
{
// X.509 certificate (base64 encoded)
Type = IdentityServerConstants.SecretTypes.X509CertificateBase64,
Value = "MIID...xBXQ="
},
new Secret
{
// or a JWK formatted RSA key
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
Value = "{'e':'AQAB','kid':'Zz...GEA','kty':'RSA','n':'wWw...etgKw'}"
}
],
// ...
}
]);You can register both types of secrets at the same time, which makes it easy to roll over keys: add a new secret, deploy the updated client with the new key, and then remove the old secret.
Creating Client Assertions for Private Key JWTs
When the client authenticates, it doesn't send a secret directly. Instead, it creates a short-lived JWT called a client assertion, and signs it with its private key. The JWT contains claims identifying the client and the intended audience (the token endpoint), and is sent to IdentityServer in the client_assertion body field along with a client_assertion_type of urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
Using the Microsoft JWT library, you can create this client assertion JWT:
Csharp
private static string CreateClientToken(
SigningCredentials credential, string clientId, string tokenEndpoint)
{
var now = DateTime.UtcNow;
var token = new JwtSecurityToken(
clientId,
tokenEndpoint,
new List<Claim>()
{
new Claim(JwtClaimTypes.JwtId, Guid.NewGuid().ToString()),
new Claim(JwtClaimTypes.Subject, clientId),
new Claim(JwtClaimTypes.IssuedAt, now.ToEpochTime().ToString(),
ClaimValueTypes.Integer64)
},
now,
now.AddMinutes(1),
credential
);
var tokenHandler = new JwtSecurityTokenHandler();
return tokenHandler.WriteToken(token);
}Note how the token is set to expire after just one minute. These assertions are meant to be single-use and short-lived.
The SigningCredentials instance is created using the public/private key pair on the client's side. For example, if the client uses an RS256 JSON Web Key, you would load the JsonWebKey, followed by creating the signing credentials:
Csharp
using Microsoft.IdentityModel.Tokens;
// Of course, this key can be loaded from configuration, a data store or a secure store like Azure KeyVault
var rsaKey = "{'e':'AQAB','kid':'Zz...GEA','kty':'RSA','n':'wWw...etgKw', 'd':'...', 'dp':'...','dq':'...', ...}";
var jwk = new JsonWebKey(rsaKey);
var signingCredentials = new SigningCredentials(jwk, "RS256");Using Client Assertions in Console, Desktop or Mobile Clients
Using Duende.IdentityModel, you can request a token using the client assertion:
Csharp
public class TokenRetriever
{
public async Task<TokenResponse> GetTokenAsync(SigningCredentials credential)
{
var client = new HttpClient();
var disco = await client.GetDiscoveryDocumentAsync(
"https://localhost:5001");
var clientToken = CreateClientToken(
credential, "confidential.client", disco.TokenEndpoint);
var response = await client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
Scope = "api1",
ClientAssertion =
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientToken
}
});
return response;
}
}We have a sample demonstrating JWT-based client authentication in case you want to see this in action.
Using Client Assertions in ASP.NET Core Clients
The OpenID Connect authentication handler in ASP.NET Core doesn't directly support private key JWT authentication, but it does allow you to replace a static client secret with a dynamically created client assertion. You can do this by handling the AuthorizationCodeReceived event on the authentication handler's events.
We recommend encapsulating the event handler in a separate type. This makes it easier to consume services from dependency injection:
Csharp
builder.Services.AddTransient<OidcEvents>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://localhost:5001";
options.ClientId = "confidential.client";
// No client secret: private key JWT is used for authentication
options.EventsType = typeof(OidcEvents);
}); In the OidcEvents event handler class, inject code before the handler redeems the authorization code:
Csharp
public class OidcEvents : OpenIdConnectEvents
{
private readonly AssertionService _assertionService;
public OidcEvents(AssertionService assertionService)
{
_assertionService = assertionService;
}
public override Task AuthorizationCodeReceived(
AuthorizationCodeReceivedContext context)
{
context.TokenEndpointRequest.ClientAssertionType = OidcConstants.ClientAssertionTypes.JwtBearer;
context.TokenEndpointRequest.ClientAssertion = _assertionService.CreateClientToken();
return Task.CompletedTask;
}
} The AssertionService is a helper class that implements the CreateClientToken method shown above, loading the signing key from your preferred key storage mechanism to create the assertion.
We have a full sample showing private key JWT client authentication in an ASP.NET Core MVC web application, including an implementation of the AssertionService class.
Adding Mutual TLS Authentication to an Application
Setting up mutual TLS (mTLS) support for Duende IdentityServer requires several additional steps. First, you'll need to decide when mTLS is being applied in IdentityServer:
- Using the
/connect/mtlspath. This is the default behaviour when enabling mTLS in IdentityServer.
Incoming requests to, for example, the token endpoint need to use/connect/mtls/tokeninstead of/connect/token. - Using a subdomain. When configuring IdentityServer's mTLS options, setting the
DomainNameto"mtls"will cause IdentityServer to serve specific endpoints on the"mtls"subdomain of your issuer URI.
Incoming requests to the token endpoint athttps://login.acme.org/connect/tokenwill need to usehttps://mtls.login.acme.org/connect/tokeninstead when using mTLS. - Using a fully qualified domain. When configuring IdentityServer's mTLS options, setting the
DomainNameto"mtls.acme.org"will cause IdentityServer to serve specific endpoints on the configured domain. In this scenario, you'll also need to configure a staticIssuerUrito ensure that issued tokens have the correct issuer claim.
Incoming requests to the token endpoint athttps://login.acme.org/connect/tokenwill need to usehttps://mtls.acme.org/connect/tokeninstead when using mTLS.
💡 Tip - When your IdentityServer is hosting multiple issuer URIs, then mTLS is only supported by configuring a subdomain or using the /mtls path option. You also need to configure IdentityServer to enable mTLS support, and register client secret validators supporting mTLS using AddMutualTlsSecretValidators:
Csharp
var isBuilder = builder.Services.AddIdentityServer(options =>
{
options.MutualTls.Enabled = true;
options.MutualTls.DomainName = "mtls"; // using the subdomain option
});
isBuilder.AddMutualTlsSecretValidators();Accepting the mTLS certificate in ASP.NET Core
Whenever a client interacts with IdentityServer on the mTLS domain or /mtls path, the client provides a client certificate. IdentityServer's MutualTlsEndpointMiddleware will trigger ASP.NET Core's certificate authentication handler to validate the incoming client certificate. The client certificate authentication handler is configured like any other ASP.NET Core authentication handler:
Csharp
var isBuilder = builder.Services.AddIdentityServer(options =>
{
options.MutualTls.Enabled = true;
options.MutualTls.DomainName = "mtls";
// "Certificate" is the default authentication scheme name
options.MutualTls.ClientCertificateAuthenticationScheme = "Certificate";
});
isBuilder.AddMutualTlsSecretValidators();
builder.Services.AddAuthentication()
.AddCertificate("Certificate", options =>
{
// On local dev environments, you may need to relax these settings
options.AllowedCertificateTypes = CertificateTypes.Chained;
options.ValidateCertificateUse = true;
});For more information about configuring certificate authentication in ASP.NET Core, head over to Microsoft Learn.
Optional: Certificate forwarding
Your production environment may host IdentityServer behind a reverse proxy, like Nginx. In this scenario, the reverse proxy is responsible for forwarding the incoming client certificate to Kestrel using a request header.
On the ASP.NET Core side in IdentityServer, the incoming network request no longer has the client certificate, requiring a slightly different setup:
Csharp
// Kestrel: there will be no incoming client certificate at the TLS layer
builder.WebHost.ConfigureKestrel(serverOptions =>
{
serverOptions.ConfigureHttpsDefaults(httpsOptions =>
{
httpsOptions.ClientCertificateMode = ClientCertificateMode.NoCertificate;
});
});
// Instead, retrieve the client certificate from the forwarded header
builder.Services.AddCertificateForwarding(options =>
{
// This header name must match the one set by your reverse proxy
options.CertificateHeader = "X-SSL-CERT";
options.HeaderConverter = headerValue =>
{
if (string.IsNullOrWhiteSpace(headerValue))
return null;
// Reverse proxies typically URL-encode PEM data
var certPem = Uri.UnescapeDataString(headerValue);
return X509Certificate2.CreateFromPem(certPem);
};
}); In IdentityServer's request pipeline, ensure that certificate forwarding is used before UseAuthentication:
Csharp
app.UseCertificateForwarding();
app.UseAuthentication();
app.UseAuthorization();Depending on the type of reverse proxy you're using, configuring client certificate forwarding requires different steps. We've provided instructions for Microsoft IIS, Nginx and Apache on our documentation site.
Configuring the client in IdentityServer
At this moment, IdentityServer will accept client certificates and knows how to react to incoming mTLS requests. However, the client still needs to be configured with its mTLS client secret to match the incoming client certificate.
Csharp
isBuilder.AddInMemoryClients([
new Client() {
ClientId = "confidential.client",
ClientSecrets =
[
// name based
new Secret("CN=confidential.client, OU=production, O=acme.org")
{
Type = IdentityServerConstants.SecretTypes.X509CertificateName
},
// or thumbprint based
new Secret("bca0d040847f843c5ee0fa6eb494837470155868")
{
Type = IdentityServerConstants.SecretTypes.X509CertificateThumbprint
}
],
// ...
}
]);Sending The Client Certificate to IdentityServer
The final step when setting up mTLS, involves configuring the client itself to add its client certificate when reaching out to IdentityServer.
Sending Client Certificates from Console, Desktop or Mobile Clients
In .NET, this is done by configuring a SocketsHttpHandler and adding that handler to the HttpClient:
Csharp
X509Certificate2 cert = await LoadClientCertificateAsync();
var handler = new SocketsHttpHandler();
handler.SslOptions.ClientCertificates = new X509CertificateCollection { cert };
var httpClient = new HttpClient(handler); Alternatively, if your application has a service collection, you can register an HTTP client instead to resolve a specific client using the HttpClientFactory:
Csharp
builder.Services.AddHttpClient("mtls")
.ConfigurePrimaryHttpMessageHandler(() =>
{
var certPath = builder.Configuration["CertificateSettings:Path"];
var certPassword = builder.Configuration["CertificateSettings:Password"];
var cert = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
return new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
ClientCertificates = new X509CertificateCollection { cert }
}
};
}); Using Duende IdentityModel, you can then request a token. Note that we're also changing the ClientCredentialStyle to PostBody. The default style is AuthorizationHeader, which does not work in an mTLS scenario.
Csharp
public class TokenRetriever(IHttpClientFactory httpClientFactory)
{
public async Task<TokenResponse> GetTokenAsync()
{
const string TokenUri = "https://localhost:5001/connect/token";
var client = httpClientFactory.CreateClient("mtls");
var request = new ClientCredentialsTokenRequest
{
Address = TokenUri,
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientId = "confidential.client",
Scope = "api1"
}
return await client.RequestClientCredentialsTokenAsync(request);
}
}Sending Client Certificates from ASP.NET Core Clients
Enabling an ASP.NET Core client application to apply mTLS during OpenID Connect flows requires a different approach. In this scenario, you'll need to configure the OpenID Connect authentication handler to use a SocketsHttpHandler for its back-channel HTTP handler.
Csharp
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://localhost:5001";
options.ClientId = "confidential.client";
// No client secret — mTLS client certificate is used for authentication
var discoDocRetriever = new HttpDocumentRetriever(new HttpClient());
options.ConfigurationManager = new MtlsConfigurationManager(
$"{options.Authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
discoDocRetriever);
// Configure mTLS client certificate for backchannel (token endpoint) calls
var certPath = builder.Configuration["CertificateSettings:Path"];
var certPassword = builder.Configuration["CertificateSettings:Password"];
var cert = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPassword);
options.BackchannelHttpHandler = new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
ClientCertificates = new X509CertificateCollection { cert}
}
};
}); There is another caveat: the OIDC handler doesn't detect the "mtls_endpoint_aliases" list from the discovery document, which requires a customized configuration manager implementation. The sample above already uses an instance of MtlsConfigurationManager; here's a sample implementation of the configuration manager:
Csharp
using System.Text.Json;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
/// <summary>
/// Wraps the standard OpenID Connect configuration manager to apply
/// mtls_endpoint_aliases from the discovery document.
/// </summary>
internal sealed class MtlsConfigurationManager :
IConfigurationManager<OpenIdConnectConfiguration>
{
private readonly ConfigurationManager<OpenIdConnectConfiguration> _inner;
private readonly string _metadataAddress;
private readonly IDocumentRetriever _docRetriever;
private static readonly Dictionary<string, Action<OpenIdConnectConfiguration, string>> EndpointSetters = new()
{
["token_endpoint"] = (c, v) => c.TokenEndpoint = v,
["introspection_endpoint"] = (c, v) => c.IntrospectionEndpoint = v,
["device_authorization_endpoint"] = (c, v) => c.DeviceAuthorizationEndpoint = v,
["pushed_authorization_request_endpoint"] = (c, v) => c.PushedAuthorizationRequestEndpoint = v,
};
// OpenIdConnectConfiguration doesn't have a dedicated RevocationEndpoint property,
// so revocation_endpoint from mtls_endpoint_aliases is stored as additional data.
private static readonly string[] AdditionalEndpointKeys = ["revocation_endpoint"];
public MtlsConfigurationManager(
string metadataAddress,
OpenIdConnectConfigurationRetriever retriever,
IDocumentRetriever docRetriever)
{
_metadataAddress = metadataAddress;
_docRetriever = docRetriever;
_inner = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress, retriever, docRetriever);
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancel)
{
var config = await _inner.GetConfigurationAsync(cancel);
var json = await _docRetriever.GetDocumentAsync(_metadataAddress, cancel);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("mtls_endpoint_aliases",
out var aliases))
{
return config;
}
foreach (var (key, setter) in EndpointSetters)
{
if (aliases.TryGetProperty(key, out var value))
{
setter(config, value.GetString()!);
}
}
foreach (var key in AdditionalEndpointKeys)
{
if (aliases.TryGetProperty(key, out var value))
{
config.AdditionalData[key] = value.GetString()!;
}
}
return config;
}
public void RequestRefresh() => _inner.RequestRefresh();
}Key Takeaways for Secure Client Authentication
Our recommendation? Start with client secrets for development and simple deployments, move to private key JWT when you want or need stronger security without infrastructure changes, and consider mutual TLS when you need the highest level of assurance or are targeting FAPI compliance.
Client secrets are the easiest way to get started with client authentication, and they work just fine for many scenarios. But when you're ready to level up the security of your confidential clients, private key JWT and mutual TLS both offer significant advantages: the secret material never leaves the client, and the authorization server never needs to store sensitive credentials.
Of the two asymmetric methods, private key JWT is the easier one to adopt. It doesn't require any infrastructure changes: you just need a key pair and a few lines of code to create the client assertion. Mutual TLS is the most secure option, but it comes with additional complexity: you need to configure TLS client certificate negotiation, potentially set up certificate forwarding through reverse proxies, and implement a custom configuration manager to handle the mtls_endpoint_aliases in the discovery document.
For more information about client authentication in Duende IdentityServer, head over to our documentation.