Skip to content

Private Key JWT Authentication (RFC 7523)

Private Key JWT (private_key_jwt) is an OAuth 2.0 client authentication method defined in RFC 7523 and referenced by OpenID Connect Core Section 9. Instead of sending a shared client_secret, the client proves its identity by signing a JWT with its private key. The authorization server validates the signature using the client’s pre-registered public key.

The default client_secret_post method sends the secret in every token request:

POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=my-app
&client_secret=super-secret-value

This creates several risks:

  • Secret leakage — the secret appears in POST bodies, potentially logged by proxies or WAFs
  • Secret rotation — changing a secret requires coordinated deployment of both client and server
  • Shared knowledge — both parties know the secret, making attribution difficult after a breach
  • Credential stuffing — stolen secrets can be replayed from any network location

The client generates a short-lived JWT assertion signed with its private key. The server only stores the public key and verifies the signature — no shared secret ever crosses the network.

sequenceDiagram
    participant Client as BFF / Confidential Client
    participant IdP as OpenIddict Server

    Note over Client: Load EC P-256 private key<br/>(from Vault or config)
    Client->>Client: Build JWT assertion<br/>(iss=client_id, sub=client_id,<br/>aud=token_endpoint, exp=+60s)
    Client->>Client: Sign with private key (ES256)

    Client->>IdP: POST /connect/token<br/>client_id=my-app<br/>client_assertion=eyJ...<br/>client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer<br/>grant_type=client_credentials
    Note over IdP: Look up client's registered<br/>public key (JWKS)<br/>Verify JWT signature<br/>Check iss, sub, aud, exp
    IdP-->>Client: { access_token, token_type: "Bearer" }
  1. Key generation — The client generates an EC P-256 (or RSA) key pair offline. The private key is stored securely (Vault, HSM, or encrypted config). The public key is registered with the authorization server.

  2. Assertion creation — On each token request, the client builds a JWT with iss and sub set to the client ID, aud set to the token endpoint URL, a unique jti, and a short exp (60 seconds in Granit).

  3. Signature — The JWT is signed with the private key using ES256 (EC) or PS256 (RSA).

  4. Token request — The signed assertion is sent as client_assertion alongside client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer.

  5. Server validation — OpenIddict retrieves the client’s registered JWKS, verifies the JWT signature, and validates the claims (iss, sub, aud, exp, jti).

Each assertion is a short-lived JWT signed by the client’s private key:

Header:

{
"typ": "client-authentication+jwt",
"alg": "ES256"
}

Payload:

{
"iss": "my-app",
"sub": "my-app",
"aud": "https://auth.example.com/connect/token",
"jti": "a1b2c3d4e5f6",
"iat": 1711148400,
"exp": 1711148460
}
ClaimRequiredDescription
issYesClient ID (issuer of the assertion)
subYesClient ID (subject of the assertion)
audYesToken endpoint URL (audience)
jtiYesUnique token ID (prevents replay)
iatYesIssued-at timestamp
expYesExpiration (60 seconds in Granit)

Generate an EC P-256 key pair using OpenSSL:

Terminal window
# Generate EC P-256 private key
openssl ecparam -name prime256v1 -genkey -noout -out client-key.pem
# Export as JWK (requires step-cli or similar tool)
step crypto key format --jwk < client-key.pem > client-key.jwk
# Extract public key only (for server registration)
step crypto key format --jwk --no-password < client-key.pem \
| jq 'del(.d)' > client-key-pub.jwk

Or using .NET:

using System.Security.Cryptography;
using System.Text.Json;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
ECParameters parameters = ecdsa.ExportParameters(includePrivateParameters: true);
// Private key JWK (store in Vault — never in appsettings.json)
string privateJwk = JsonSerializer.Serialize(new
{
kty = "EC", crv = "P-256",
x = Base64UrlEncode(parameters.Q.X!),
y = Base64UrlEncode(parameters.Q.Y!),
d = Base64UrlEncode(parameters.D!),
});
// Public key JWK (register on the OIDC server)
string publicJwk = JsonSerializer.Serialize(new
{
kty = "EC", crv = "P-256",
x = Base64UrlEncode(parameters.Q.X!),
y = Base64UrlEncode(parameters.Q.Y!),
});

OpenIddict server — seeding with public key

Section titled “OpenIddict server — seeding with public key”

Register the client’s public key using OidcApplicationSeedDescriptor.SigningKeyJwk:

{
"OpenIddict": {
"Seeding": {
"Applications": [
{
"ClientId": "my-bff",
"ClientSecret": null,
"DisplayName": "My BFF (private_key_jwt)",
"SigningKeyJwk": "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"...\",\"y\":\"...\"}",
"Permissions": [
"ept:token", "ept:authorization", "ept:pushed_authorization",
"gt:authorization_code", "gt:refresh_token", "rst:code",
"scp:openid", "scp:profile", "scp:email"
],
"RedirectUris": ["https://app.example.com/bff/callback"],
"PostLogoutRedirectUris": ["https://app.example.com/"]
}
]
}
}
}

Or in code:

new OidcApplicationSeedDescriptor(
ClientId: "my-bff",
ClientSecret: null, // No shared secret needed
DisplayName: "My BFF (private_key_jwt)",
Permissions:
[
"ept:token", "ept:authorization", "ept:pushed_authorization",
"gt:authorization_code", "gt:refresh_token", "rst:code",
"scp:openid", "scp:profile", "scp:email",
],
RedirectUris: ["https://app.example.com/bff/callback"],
PostLogoutRedirectUris: ["https://app.example.com/"],
SigningKeyJwk: publicKeyJwkJson)

Configure the BFF frontend to use private_key_jwt instead of client_secret_post:

{
"Bff": {
"Frontends": [
{
"Name": "admin",
"ClientId": "my-bff",
"ClientAuthenticationMethod": "PrivateKeyJwt",
"ClientSigningKeyJwk": "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"...\",\"y\":\"...\",\"d\":\"...\"}"
}
]
}
}

When ClientAuthenticationMethod is set to PrivateKeyJwt, the BFF:

  • Builds a fresh JWT assertion for every token request (login, refresh, PAR)
  • Signs the assertion with the configured private key (ES256 for EC, PS256 for RSA)
  • Sends client_assertion + client_assertion_type instead of client_secret
  • Omits the client_secret parameter entirely

Shared secret vs private_key_jwt comparison

Section titled “Shared secret vs private_key_jwt comparison”
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=my-app
&client_secret=super-secret-value
Secret visible in POST body.
Leaked secret = full impersonation until rotated.
Rotation requires coordinated redeployment.
Threatclient_secret_postprivate_key_jwt
Secret logged by proxy/WAFSecret in POST bodyNo secret on the wire
Credential replaySecret valid until rotatedAssertion expires in 60s
Server-side breachAttacker gets hashed secretAttacker gets public key (useless)
Key rotationCoordinated deploymentIndependent: new public key on server, new private key on client
Attribution after breachBoth parties know the secretOnly the client holds the private key
Credential stuffingPossible with stolen secretImpossible without private key
AspectGranit implementation
AlgorithmsEC P-256 (ES256) — recommended; RSA (PS256) — supported
Key lifetimeLong-lived (rotate annually or per security policy)
Private key storageVault, HSM, or encrypted environment variable
Public key storageOpenIddict application JWKS (via SigningKeyJwk seeding)
Assertion lifetime60 seconds per assertion JWT
Assertion uniquenessjti claim (random GUID per assertion)

private_key_jwt is one of two client authentication methods allowed by the FAPI 2.0 Security Profile (the other being mTLS). Combined with PAR and DPoP, it provides a complete FAPI 2.0-compliant flow:

MechanismProtectsRFC
PKCEAuthorization code interceptionRFC 7636
PARAuthorization parameter tampering + PII leakageRFC 9126
DPoPToken replay + theftRFC 9449
private_key_jwtClient authentication without shared secretsRFC 7523

Enable all four for maximum security:

{
"Bff": {
"Frontends": [
{
"Name": "admin",
"ClientAuthenticationMethod": "PrivateKeyJwt",
"ClientSigningKeyJwk": "{...private key JWK...}",
"UsePushedAuthorizationRequests": true,
"UseDPoP": true
}
]
}
}

PKCE is enforced by default (RequireProofKeyForCodeExchange).