docs/en/Community-Articles/2026-03-12-OpenIddict-private-key-jwt/POST.md
If you've built a confidential client with ABP's OpenIddict module, you know the drill: create an application in the management UI, set a client_id, generate a client_secret, and paste that secret into your client's appsettings.json or environment variables. It works. It's familiar. And for a lot of projects, it's perfectly fine.
But client_secret is a shared secret — and shared secrets carry an uncomfortable truth: the same value exists in two places at once. The authorization server stores a hash of it in the database, and your client stores the raw value in configuration. That means two potential leak points. Worse, the secret has no inherent identity. Anyone who obtains the string can impersonate your client and the server has no way to tell the difference.
For many teams, this tradeoff is acceptable. But certain scenarios make it hard to ignore:
client_secret scattered across deployment configs and CI/CD pipelines. Rotating them across environments without missing one becomes a coordination problem.client_secret doesn't make the cut.The underlying problem is that a shared secret is just a password. It can be stolen, replicated, and used without leaving a trace. The fix has existed in cryptography for decades: asymmetric keys.
With asymmetric key authentication, the client generates a key pair. The public key is registered with the authorization server. The private key never leaves the client. Each time the client needs a token, it signs a short-lived JWT — called a client assertion — with the private key. The server verifies the signature using the registered public key. There is no secret on the server side that could be used to forge a request, because the private key is never transmitted or stored remotely.
This is exactly what the private_key_jwt client authentication method, defined in OpenID Connect Core, provides. ABP's OpenIddict module now supports it end-to-end: you register a JSON Web Key Set (JWKS) containing your public key through the application management UI (ABP Commercial), and your client authenticates using the corresponding private key. The key generation tooling (abp generate-jwks) ships as part of the open-source ABP CLI.
This feature is available starting from ABP Framework 10.3.
The flow is straightforward:
jti claim.The private key never leaves the client. Even if someone obtains the authorization server's database, there's nothing there that can be used to generate a valid client assertion.
ABP CLI includes a generate-jwks command that creates an RSA key pair in the right formats:
abp generate-jwks
This produces two files in the current directory:
jwks.json — the public key in JWKS format, to be uploaded to the serverjwks-private.pem — the private key in PKCS#8 PEM format, to be kept on the clientYou can customize the output directory, key size, and signing algorithm:
abp generate-jwks --alg RS512 --key-size 4096 -o ./keys -f myapp
Supported algorithms:
RS256,RS384,RS512,PS256,PS384,PS512. The default isRS256with a 2048-bit key.
The command also prints the contents of jwks.json to the console so you can copy it directly.
Open OpenIddict → Applications in the ABP admin panel and create or edit a confidential application (Client Type: Confidential).
In the Client authentication method section, you'll find the new JSON Web Key Set field.
Paste the contents of jwks.json into the JSON Web Key Set field:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "6444...",
"alg": "RS256",
"n": "tx...",
"e": "AQAB"
}
]
}
Save the application. It's now configured for private_key_jwt authentication. You can set either client_secret or a JWKS, or both — ABP enforces that a confidential application always has at least one credential.
On the client side, each token request requires building a client assertion JWT signed with the private key. Here's a complete client_credentials example:
// Discover the authorization server endpoints (including the issuer URI).
var client = new HttpClient();
var configuration = await client.GetDiscoveryDocumentAsync("https://your-auth-server/");
// Load the private key generated by `abp generate-jwks`.
using var rsaKey = RSA.Create();
rsaKey.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));
// Read the kid from jwks.json so it stays in sync with the server-registered public key.
string? signingKid = null;
if (File.Exists("jwks.json"))
{
using var jwksDoc = JsonDocument.Parse(await File.ReadAllTextAsync("jwks.json"));
if (jwksDoc.RootElement.TryGetProperty("keys", out var keysElem) &&
keysElem.GetArrayLength() > 0 &&
keysElem[0].TryGetProperty("kid", out var kidElem))
{
signingKid = kidElem.GetString();
}
}
var signingKey = new RsaSecurityKey(rsaKey) { KeyId = signingKid };
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
// Build the client assertion JWT.
var now = DateTime.UtcNow;
var jwtHandler = new JsonWebTokenHandler();
var clientAssertionToken = jwtHandler.CreateToken(new SecurityTokenDescriptor
{
// OpenIddict requires typ = "client-authentication+jwt" for client assertion JWTs.
TokenType = "client-authentication+jwt",
Issuer = "MyClientId",
// aud must equal the authorization server's issuer URI from the discovery document,
// not the token endpoint URL.
Audience = configuration.Issuer,
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, "MyClientId"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(5),
SigningCredentials = signingCredentials,
});
// Request a token using the client_credentials flow.
var tokenResponse = await client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = configuration.TokenEndpoint,
ClientId = "MyClientId",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientAssertionToken,
},
Scope = "MyAPI",
});
A few things worth paying attention to:
TokenType must be "client-authentication+jwt". OpenIddict rejects client assertion JWTs that don't carry this header.Audience must match the authorization server's issuer URI exactly — use configuration.Issuer from the discovery document, not the token endpoint URL.Jti must be unique per request to prevent replay attacks.Expires short (five minutes or less). A client assertion is a one-time proof of identity, not a long-lived credential.This example uses IdentityModel for the token request helpers and Microsoft.IdentityModel.JsonWebTokens for JWT creation.
One of the practical advantages of JWKS is that it can hold multiple public keys simultaneously. This makes zero-downtime key rotation straightforward:
abp generate-jwks to produce a new key pair.keys array in your existing jwks.json and update the JWKS in the management UI.During the transition window, both the old and new public keys are registered on the server, so any in-flight requests signed with either key will still validate correctly.
To use private_key_jwt authentication in an ABP Pro application:
abp generate-jwks to generate an RSA key pair.jwks.json contents into the JSON Web Key Set field in the OpenIddict application management UI.typ, aud (from the discovery document), and a unique jti — then use it to request a token.ABP handles public key storage and validation automatically. OpenIddict handles the signature verification on the token endpoint. As a developer, you only need to keep the private key file secure — there's no shared secret to synchronize between client and server.