My logo
Published on

Private Key JWTs for better client authentication

Introduction

Private key JWTs is an attempt to get better at authenticating clients. So, rather than sending a client secret, the client identifies itself by sending a signed JWT to the authorizing server.

Problems with client ids and client secrets

Authenticating a client using client id and client secrets is a very rudimentary form of authentication. IDP cannot definately ensure the client is who it says it is. And, secrets must be stored in a secure location, azure key vault perhaps, or in the applications settings if your are hosting on azure, but they may also get checked into source control, so managing secrets is not a trivial task, its cumbersome at best.

A more resilient solution using Private Key JWTs

Better alternate is to use private key JWTs 1

JSON signed by a private key results in a JSON Web Token or JWT.

Here's what the decoded JSON Web Token looks like,

{
  "alg": "RS256",
  "kid": "95983758CEA29458A32D90FC436FF2EEE8DE4507",
  "typ": "JWT"
}.{
  "jti": "125d131e-43dc-49b0-90e6-2b99a4fe79e9",
  "sub": "notesmvcappprivatekeyjwt",
  "iat": 1699904460,
  "nbf": 1699904460,
  "exp": 1699904520,
  "iss": "notesmvcappprivatekeyjwt",
  "aud": "https://localhost:5001/connect/token"
}.[Signature]

And, here are the claims

Claim typeValueNotes
jti125d131e-43dc-49b0-90e6-2b99a4fe79e9The "jti" (JWT ID) claim provides a unique identifier for the JWT. The identifier value MUST be assigned in a manner that ensures that there is a negligible probability that the same value will be accidentally assigned to a different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced by different issuers as well. The "jti" claim can be used to prevent the JWT from being replayed. The "jti" value is a case-sensitive string. [RFC 7519, Section 4.1.7]
subnotesmvcappprivatekeyjwtThe "sub" (subject) claim identifies the principal that is the subject of the JWT. The claims in a JWT are normally statements about the subject. The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique. The processing of this claim is generally application specific. The "sub" value is a case-sensitive string containing a StringOrURI value. [RFC 7519, Section 4.1.2]
iatMon Nov 13 2023 14:41:00 GMT-0500 (Eastern Standard Time)The "iat" (issued at) claim identifies the time at which the JWT was issued. This claim can be used to determine the age of the JWT. [RFC 7519, Section 4.1.6]
nbfMon Nov 13 2023 14:41:00 GMT-0500 (Eastern Standard Time)The "nbf" (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing. Implementers 6AY provide for some small leeway, usually no more than a few minutes, to account for clock skew. [RFC 7519, Section 4.1.5
expMon Nov 13 2023 14:42:00 GMT-0500 (Eastern Standard Time)The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. [RFC 7519, Section 4.1.4
issnotesmvcappprivatekeyjwtThe "iss" (issuer) claim identifies the principal that issued the JWT. The processing of this claim is generally application specific. The "iss" value is a case-sensitive string containing a StringOrURI value. [RFC 7519, Section 4.1.1
audhttps://localhost:5001/connect/tokenThe "aud" (audience) claim identifies the recipients that the JWT is intended for. Each principal intended to process the JWT MUST identify itself with a value in the audience claim. If the principal processing the claim does not identify itself with a value in the "aud" claim when this claim is present, then the JWT MUST be rejected. [RFC 7519, Section 4.1.3

The client then makes a request for an access token to the authorization server’s token endpoint including the client_assertion_type, client_assertion and the grant_type parameters.

Here's a sample of the debug output at the IDP's end when the token is validated.

Duende.IdentityServer.Validation.TokenRequestValidator
Token request validation success, {"ClientId": "notesmvcappprivatekeyjwt", "ClientName": null,
"GrantType": "authorization_code", "Scopes": null, "AuthorizationCode": "****1A-1",
"RefreshToken": "********", "UserName": null, "AuthenticationContextReferenceClasses": null,
"Tenant": null, "IdP": null, "Raw": {"client_id": "notesmvcappprivatekeyjwt", "code": "***REDACTED***",
"grant_type": "authorization_code", "redirect_uri": "https://localhost:7123/signin-codeflowprivatekeyjwt",
"code_verifier": "ZTm4lZjiYgVGs0HtvZu34lCogNawD6nOlSjRefqavLk",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "***REDACTED***"}, "$type": "TokenRequestValidationLog"}

client_assertion is the signed JWT token and the client_assertion_type is urn:ietf:params:oauth:client-assertion-type:jwt-bearer

The IDP extracts the JWT from the client_assertion and this extracted JWT is then validated. by the public key that is shared with the IDP 2. So, when we say validated by the pubic key then what that means is that once the authorization server has extract the client's assertion which is the signed JWT from the request, then it can verify that the JWT has not been tampered with by using the public key.

Improve Manageability

Rather than the IDP keeping the public key of the client, the client can publish its public key to a well known location and the IDP can then fetch the public key from there. So, there are two advantages of this

  1. the client can rotate its keys without the IDP having to know about it.
  2. the IDP can fetch the public key from a well known location rather than having to store it locally so the client is managing the keypair.

Footnotes

  1. JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants

  2. This is also known as asymmetric cryptography where pair of related keys, one public and one private are used to encrypt and decrypt a message