A client secret is still the default way most OAuth clients prove who they are to a token endpoint, and that is exactly the weak point in the design. The secret sits in a config file or a Key Vault, gets copied into a pipeline variable, and sooner or later ends up somewhere it should not be. A client assertion replaces that shared secret with a signed JWT, so the client proves possession of a private key instead of handing over a value that anyone with read access to the configuration can also read.
This post covers implementing client assertions for the OAuth client credentials flow in ASP.NET Core, with Duende IdentityServer acting as the authorization server. The setup uses three pieces: an API that validates the access token, a console application acting as the OAuth client, and the IdentityServer itself. An RSA key pair drives the whole exchange, and the private key never leaves the client machine.
Why move away from client secrets
A client secret is a symmetric value. Both the client and the authorization server need to know it, which means it has to be stored, transmitted, and rotated on both sides. If it leaks from either side, anyone who has it can request tokens on behalf of that client until someone notices and rotates it.
A client assertion works differently. The client signs a short lived JWT with its own private key and sends that JWT to the token endpoint instead of a secret. The server only ever needs the public key or certificate to verify the signature. There is nothing secret on the server side to leak, and the JWT itself expires within minutes, so a captured token request is far less useful to an attacker than a captured client secret.
The three moving parts
The sample setup has an OAuth server built with ASP.NET Core and Duende IdentityServer, a console application that plays the role of the OAuth client, and an API that trusts access tokens issued by the server. The client credentials grant is used to get the access token, and the signed JWT client assertion replaces the client secret in that token request. The full source is on GitHub, linked at the end of this article.
Building the JWT in the console client
The console client is built with the Duende.IdentityModel and Duende.AccessTokenManagement.OpenIdConnect NuGet packages. The signing key in this sample is loaded from PEM files, but it could just as easily come from a certificate store or a key management service. Only the private key is required, since that is what signs the assertion.
var privatePem = File.ReadAllText(Path.Combine("", "rsa256-private.pem"));
var publicPem = File.ReadAllText(Path.Combine("", "rsa256-public.pem"));
var rsaCertificate = X509Certificate2.CreateFromPem(publicPem, privatePem);
var signingCredentials = new SigningCredentials(new X509SecurityKey(rsaCertificate), "RS256");
This block reads both PEM files, builds an X509Certificate2 from them, and wraps the certificate in a SigningCredentials object using RS256. That signingCredentials instance is what actually signs the JWT in the next step. A common mistake here is pointing the code at a certificate that only has the public key loaded, which fails the moment you try to sign anything, since signing needs the private key half of the pair.
Next, the client builds the actual JWT that will be sent as the assertion. This code is taken directly from the Duende samples and follows the client assertion structure defined in the OAuth JWT profile specification, so any compliant OAuth or OpenID Connect server can validate it.
// Code from the Duende samples.
static string CreateClientToken(SigningCredentials credential, string clientId, string audience)
{
var now = DateTime.UtcNow;
var token = new JwtSecurityToken(
clientId,
audience,
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();
var clientToken = tokenHandler.WriteToken(token);
return clientToken;
}
The issuer and audience of this JWT are both set to the client ID and the token endpoint respectively, not to any user identity, since this assertion is proving the client’s identity, not a user’s. Notice the token is only valid for one minute. That short lifetime is intentional. A client assertion is meant to be generated fresh for each token request, not cached and reused, so a one minute window is more than enough and keeps the blast radius small if a token request is intercepted in transit.
With the JWT built, the client sends it to the token endpoint as part of a standard client credentials request. Duende’s IdentityModel client makes this straightforward by exposing a ClientAssertion property on the request object.
static async Task<TokenResponse> RequestTokenAsync(SigningCredentials signingCredentials)
{
var client = new HttpClient();
var disco = await client.GetDiscoveryDocumentAsync("https://localhost:5001");
if (disco.IsError) throw new Exception(disco.Error);
var clientToken = CreateClientToken(signingCredentials, "mobile-client", disco.Issuer);
var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
ClientAssertion =
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientToken
},
Scope = "mobile",
});
if (response.IsError) throw new Exception(response.Error);
return response;
}
The discovery document call finds the token endpoint and issuer automatically, so the client does not need to hardcode either value. Note there is no ClientSecret anywhere in this request, it has been replaced entirely by ClientAssertion.Type and ClientAssertion.Value. If the server rejects this request, the most common causes are a clock skew between client and server pushing the JWT outside its one minute validity window, or the audience value not matching what the server expects for its token endpoint.
Configuring the OAuth server with Duende IdentityServer
On the server side, the client registration needs to know how to verify the incoming assertion. Instead of storing a shared secret value, the client configuration stores the client’s public certificate as a base64 encoded secret of type X509CertificateBase64.
new Client
{
ClientId = "mobile-client",
ClientName = "Mobile client",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets =
[
new Secret
{
// X509 cert base64-encoded
Type = IdentityServerConstants.SecretTypes.X509CertificateBase64,
Value = Convert.ToBase64String(rsaCertificate.GetRawCertData())
}
],
AllowedScopes = { "mobile" }
},
Despite living in the ClientSecrets collection, this is not a secret in the traditional sense since it is only the public certificate data. Anyone who reads this value from the server configuration cannot forge a valid client assertion with it, they would still need the private key that only the client holds. This is the core security improvement over a shared client secret.
The server also needs the middleware wired up to actually validate assertions during token requests. Duende IdentityServer provides a single extension method for this.
var idsvrBuilder = builder.Services
.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
options.EmitStaticAudienceClaim = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients(builder.Environment))
.AddAspNetIdentity<ApplicationUser>();
idsvrBuilder.AddJwtBearerClientAuthentication();
AddJwtBearerClientAuthentication registers the validator that checks incoming client assertions against the registered certificate for that client ID, confirms the JWT signature, and enforces the standard claims like expiry and audience. Without this call, the server has no idea what to do with a ClientAssertion field on the token request and the client configuration from the previous step has no effect.
Practical considerations and trade-offs
The sample uses an RSA key, but the pattern is not tied to RSA specifically. Other key types and sizes work as well, and it is worth checking the current NIST recommendations rather than defaulting to whatever key size an old tutorial used. What matters more than the algorithm choice is how the private key is stored and who can access it.
This is the one caveat worth taking seriously before adopting client assertions in production. If both the client and the server end up reading the private key from the same shared Azure Key Vault, the security improvement over a plain client secret mostly disappears, because anyone with access to that vault can still impersonate the client. Client assertions only pay off when the private key genuinely stays isolated to the client, ideally generated and stored on the client’s own host, HSM, or managed identity, and never replicated anywhere the server team can reach.
On the operational side, plan for certificate rotation from day one. A certificate that expires without a rotation plan will take down every service using that client ID, and unlike a client secret rotation, swapping a signing certificate usually means redeploying the client application with the new key material. Keep the JWT lifetime short, as in the sample’s one minute window, and monitor for clock skew between client and server hosts, since that is the most common cause of assertions being rejected as expired or not yet valid.
When this pattern is worth the extra complexity
Client assertions add real setup cost: key generation, certificate distribution, and a rotation process that a plain client secret does not need. For a low value internal service talking to another internal service, where both are already behind the same network boundary and secret rotation is automated through a vault, a client secret is often good enough and the added complexity of assertions is not worth it.
Client assertions earn their cost on higher value integrations: machine to machine access to production APIs, partner integrations crossing organizational boundaries, or any client where a leaked secret would be expensive to detect and rotate. If the client also needs to prove possession of the key at request time rather than just knowledge of a bearer token, the next step beyond this pattern is DPoP bound client credentials, which ties the access token itself to the same signing key used for the assertion.
Leave a Reply