Secure Client Authentication with private_key_jwt in ABP 10.3
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:
- Microservice-to-microservice calls: A backend mesh of a dozen services, each with its own
client_secretscattered across deployment configs and CI/CD pipelines. Rotating them across environments without missing one becomes a coordination problem. - Multi-tenant SaaS platforms: Every tenant's client application deserves truly isolated credentials. With shared secrets, the database holds hashed copies for all tenants — a breach of that table is a breach of everyone's credentials.
- Financial-grade API (FAPI) compliance: Standards like FAPI 2.0 explicitly require asymmetric client authentication.
client_secretdoesn't make the cut. - Zero-trust architectures: In a zero-trust model, identity must be cryptographically provable, not based on a string that can be copied and pasted.
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.
How It Works
The flow is straightforward:
- The client holds an RSA key pair — private key (kept locally) and public key (registered on the authorization server as a JWKS).
- On each token request, the client uses the private key to sign a JWT with a short expiry and a unique
jticlaim. - The authorization server verifies the signature against the registered public key and issues a token if it checks out.
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.
Generating a Key Pair
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 client
You 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.
Registering the JWKS in the Management UI
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.
Requesting a Token with the Private Key
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:
TokenTypemust be"client-authentication+jwt". OpenIddict rejects client assertion JWTs that don't carry this header.Audiencemust match the authorization server's issuer URI exactly — useconfiguration.Issuerfrom the discovery document, not the token endpoint URL.Jtimust be unique per request to prevent replay attacks.- Keep
Expiresshort (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.
Key Rotation Without Downtime
One of the practical advantages of JWKS is that it can hold multiple public keys simultaneously. This makes zero-downtime key rotation straightforward:
- Run
abp generate-jwksto produce a new key pair. - Append the new public key to the
keysarray in your existingjwks.jsonand update the JWKS in the management UI. - Switch the client to sign assertions with the new private key.
- Once the transition is complete, remove the old public key from the JWKS.
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.
Summary
To use private_key_jwt authentication in an ABP Pro application:
- Run
abp generate-jwksto generate an RSA key pair. - Paste the
jwks.jsoncontents into the JSON Web Key Set field in the OpenIddict application management UI. - On the client side, sign a short-lived client assertion JWT with the private key — making sure to set the correct
typ,aud(from the discovery document), and a uniquejti— 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.
Comments
No one has commented yet, be the first to comment!