Certificates as client credentials
A client can prove who it is with a certificate rather than a shared secret: by signing its private_key_jwt
assertions with a certificate's key, or by presenting the certificate itself on the TLS connection (RFC 8705). The code
on this page is included from
samples/Server.Clients, which a
test boots and checks the discovery document of.
A client registered by its certificate
// A client that signs its private_key_jwt assertions with a certificate's key, registered by the certificate
// itself: DER, base64'd, no private key. Its key is tried first, under the certificate's thumbprint as key id,
// and an assertion only an expired certificate verifies is refused.
new Client
{
ClientId = "payments-portal",
ClientSecrets =
{
new Secret(Convert.ToBase64String(paymentsCertificate.Export(X509ContentType.Cert)))
{
Type = IdentityServerConstants.SecretTypes.X509CertificateBase64,
},
},
AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
AllowedScopes = { "payments" },
},
New in 0.135.0, Duende 8.0.1's, measured. A client that signs its private_key_jwt assertions or its request
objects with a certificate's key can be registered with the certificate itself —
X509CertificateBase64, DER, base64'd, no private key —
rather than re-expressed as a JSON Web Key.
Its key is offered ahead of the client's JWKs, under the certificate's thumbprint as key id, so an assertion signed with
X509SigningCredentials names it. The certificate's validity period is enforced where the key is used: an assertion or
request object verified only by an expired certificate is refused. ClientExtensions.GetKeysAsync is the one place
secrets become keys, for both. Where ours differs: a secret that does not read — not base64, not a certificate — is
skipped and the client's other keys still work, where Duende's refuses the client outright.
The audience of a private_key_jwt assertion
An assertion (RFC 7523) must be issued by and about the client, signed by one of its keys, current, never seen before
(its jti), and addressed to this server. By default "addressed to this server" means its audiences include this
server's issuer or the URL the assertion was sent to, and its typ is not read — what clients have long sent.
New in 0.176.0, Duende 8.0.1's, measured from its PrivateKeyJwtSecretValidator:
IdentityServerOptions.StrictClientAssertionAudienceValidation (default false, as Duende's) holds every assertion to
the strict rule of draft-ietf-oauth-rfc7523bis — typed client-authentication+jwt, with exactly one audience, the issuer
(one trailing slash either way is tolerated, as Duende's comparison tolerates it). An assertion a client types
client-authentication+jwt is held to that rule whatever the option says: the client has said which rule it signed under.
The strict rule closes the audience-injection attacks published in 2025, in which an assertion that names several
audiences, or a URL that two servers share, signed for one authorization server is accepted by another.
Security — turning it on. Turn the option on once every
private_key_jwtclient sends a typed assertion addressed to the issuer alone; a client that addresses the token endpoint's URL, or sends notyp, is then refusedinvalid_client. Until 0.176.0 the parity matrix said this server's audience rule was unconditionally strict, and the option absent for that reason; it was neither.
Mutual TLS (RFC 8705)
A client can authenticate with a certificate instead of a secret, and the token it gets back is bound to that certificate. Register the certificate one of two ways:
// self_signed_tls_client_auth: this exact certificate, by its SHA-256 thumbprint.
new Client
{
ClientId = "payments-batch",
ClientSecrets =
{
new Secret(paymentsCertificate.GetCertHashString(HashAlgorithmName.SHA256))
{
Type = IdentityServerConstants.SecretTypes.X509CertificateThumbprint,
},
},
AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
AllowedScopes = { "payments" },
},
// tls_client_auth: any certificate bearing this subject, from a certificate authority the listener already
// trusts. A name is not a secret — anyone can self-sign one — so this is only as good as that chain check.
new Client
{
ClientId = "payments-reconciler",
ClientSecrets =
{
new Secret("CN=payments-batch, O=Fdyay") { Type = IdentityServerConstants.SecretTypes.X509CertificateName },
},
AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
AllowedScopes = { "payments" },
},
The name form trusts your TLS configuration, not the registration. A subject name is not a secret — anyone can
self-sign a certificate claiming any name — so it authenticates only where the listener has already rejected
certificates that do not chain to a certificate authority you trust. Configure that (Kestrel's ClientCertificateMode
plus your own chain validation) before registering a secret of this type. The thumbprint form carries no such
prerequisite.
Access tokens issued to a certificate-authenticated client carry cnf/x5t#S256, so a resource server can refuse a
token presented by anyone else. Introspection returns the same value as an object, and since 0.45.0 this server's own
resource endpoints compare it to the certificate on the connection rather than only relaying it.
The mTLS endpoint aliases
// /connect/mtls/token and the other endpoints a client authenticates at, under a second path a listener can
// demand a client certificate on — advertised as mtls_endpoint_aliases.
options.MutualTls.Enabled = true;
Turning on the aliases publishes the endpoints clients authenticate at under a second path — /connect/mtls/token,
/connect/mtls/introspect, /connect/mtls/revocation, /connect/mtls/par, /connect/mtls/deviceauthorization and
/connect/mtls/ciba — advertised as mtls_endpoint_aliases in discovery. They exist so a listener can demand a client
certificate on those paths alone; asking for one at /connect/authorize would prompt every browser user for a
certificate they do not have. Demanding the certificate at the listener is the host's job. Aliases are path-based; there
is no subdomain form, and no option pretends otherwise. The full list of paths is on endpoints.
The aliases ask the host's certificate handler first — breaking in 0.135.0, Duende 8.0.1's
MutualTlsEndpointMiddleware, measured. A request to an alias is authenticated with
MutualTlsOptions.ClientCertificateAuthenticationScheme — Certificate by default, the name ASP.NET Core's handler
registers — before the endpoint runs, and one the handler does not accept is answered
{"error":"invalid_client","error_description":"mTLS authentication failed."}. That handler is where chain, revocation
and allowed issuers are checked; the client's registered thumbprint or name is still compared afterwards. A host with
the aliases on registers one (the Microsoft.AspNetCore.Authentication.Certificate package):
// The aliases ask this handler first: chain, revocation and allowed issuers are checked here, and the client's
// registered thumbprint or subject name afterwards. Demanding the certificate is the listener's job
// (Kestrel's ClientCertificateMode), on the alias paths only.
builder.Services.AddAuthentication()
.AddCertificate(options => options.AllowedCertificateTypes = CertificateTypes.All);
Until 0.135.0 the aliases were routed like any other path. With no handler under the configured name, a request to an
alias fails with an exception naming the option, where Duende's surfaces ASP.NET Core's own. Only the aliases are asked,
where Duende asks at every path under connect/mtls — and serves the authorize endpoint there, which is not aliased
here. The settings are under MutualTlsOptions.