skip to content

In OAuth 2.0, what does private_key_jwt client authentication send to the token endpoint, and what does it buy?

level: middleimportance: should knowfreq 44%

answer

  1. no secret on the wire at all
  2. two extra form parameters, one fixed URI
  3. the client signs a short-lived statement
  4. iss and sub are both the client_id
  5. aud names the server, jti stops replay

basics

~20 s

The client posts client_assertion_type set to urn:ietf:params:oauth:client-assertion-type:jwt-bearer plus a client_assertion: a short-lived JWT it signed with its private key. The authorization server verifies it against the client's registered public key, so no shared secret ever crosses the wire.

solid answer

~50 s

Instead of a `client_secret`, the token request carries two extra parameters: `client_assertion_type` fixed to `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, and `client_assertion`, a JWT the client signed with its own private key. RFC 7523 §3 requires that assertion to carry `iss` and `sub` both set to the `client_id`, an `aud` identifying the authorization server that will receive it, and an `exp`; the profile that names `private_key_jwt` also requires a `jti` so the server can reject a replay. The server verifies the signature against the public key it holds for that client, and **MUST** reject an assertion that does not name it as the audience. What this buys: the credential itself never travels, each assertion is short-lived, audience-bound and single-use, and the authorization server stores only a public key — so breaching its client store yields nothing that can authenticate as the client.

code

http · 7 lines
http
POST /token HTTP/1.1
Host: as.example.net
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=customs.entries.write
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6ImJyb2tlci0yMDI2LTA5In0.eyJpc3MiOi...

go deeper

for a junior

Recall the shape: instead of a shared secret the client sends a short-lived signed statement, and the authorization server checks it against a public key it already holds for that client.

for a middle

Explain the two parameters and the required claims, and why iss and sub are both the client_id while aud names the authorization server receiving the assertion.

for a senior

Show the operational side: audience mismatches, clock skew against a seconds-long exp, a non-unique jti failing only under concurrency, and a key rotation with an overlap window.

for a principal

The judgement is whether an estate can run key material at all — issuing, publishing and retiring keys per client is real machinery, and its payoff is that no credential store anywhere holds something reusable.

## What goes on the wire Assertion-based client authentication replaces the shared secret in the token request with a signed statement. The request is still an ordinary form-encoded POST to the token endpoint; it simply carries two extra parameters instead of a secret: - `client_assertion_type` — a fixed URI, `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, telling the server what kind of assertion follows; - `client_assertion` — the JWT itself, signed by the client. The `client_id` parameter may also be sent, and where it is, it has to agree with the assertion. The mechanism is RFC 7521 §4.2's assertion framework with RFC 7523's JWT profile on top; the *name* `private_key_jwt` comes from the registered values for `token_endpoint_auth_method`. ## The claims the assertion has to carry | Claim | Value for client authentication | Why it is there | |---|---|---| | `iss` | the `client_id` | who made the statement | | `sub` | the `client_id` | who the statement is about — the same party, because a client is authenticating itself | | `aud` | the authorization server receiving it | stops a recipient replaying it somewhere else | | `exp` | a near-term timestamp | bounds how long a captured assertion is usable | | `jti` | a unique identifier | lets the server remember and refuse a second use | RFC 7523 §3 makes `iss`, `sub`, `aud` and `exp` required and states plainly that the authorization server **MUST** reject any assertion that does not contain its own identity as the intended audience. The `jti` is what turns "short-lived" into "single-use": without the server tracking it, an assertion captured inside its validity window can be presented again. ## What it actually buys 1. **The credential never leaves the client.** A shared secret is transmitted on every token request; a private key signs and stays put. A logging proxy, a debug capture or a mirrored request yields an expired, audience-bound assertion rather than a reusable credential. 2. **The authorization server holds no secret worth stealing.** With `client_secret_basic` the server stores a verifier for a symmetric value; with `private_key_jwt` it stores a public key. Compromising the client store gets an attacker nothing they can authenticate with. 3. **Rotation stops being a synchronised outage.** The client publishes a key set and can serve two keys during an overlap window, retiring the old one when nothing signs with it any more — no moment where both sides must swap a string at once. 4. **The statement is scoped to one recipient.** A shared secret sent to the wrong host is a leaked secret. An assertion sent to the wrong host is useless there, because `aud` names where it was meant to go. ## `client_secret_jwt` is a different trade The other assertion method looks identical on the wire and is not the same deal. `client_secret_jwt` computes a message authentication code over the assertion using the `client_secret` as the key. The secret is never transmitted, which is a genuine gain, but it is still a symmetric value both parties hold — so the authorization server's store is still worth breaching, and rotation is still a two-sided operation. `private_key_jwt` removes the shared value entirely; `client_secret_jwt` only stops sending it. ## Where the server publishes what it will take An authorization server advertises the methods it accepts in `token_endpoint_auth_methods_supported`, and the signing algorithms it will accept for an assertion in `token_endpoint_auth_signing_alg_values_supported`. A client that signs with an algorithm outside that list is refused however correct the claims are. ## The failures that show up in production - **`aud` set to the issuer when the server expects the token endpoint URL, or the reverse.** Both readings are deployed; the server's own documentation and metadata settle it, and the symptom is a flat `invalid_client`. - **Clock skew.** `exp` is usually seconds away, so a client running fast against a strict server produces assertions that are already expired on arrival. - **A reused `jti`.** A client that derives the identifier from something not actually unique — a request hash, a restart counter — authenticates successfully until traffic doubles, then intermittently fails on replay rejection. - **A rotated key the server has not fetched.** If the server caches the client's key set, retiring the old key immediately produces signature failures for as long as the cache lives; overlap is what avoids it.

  • Why must the authorization server reject an assertion whose aud does not name it?
    Because otherwise a server that receives an assertion could present it to a different authorization server and authenticate as that client. RFC 7523 §3 makes rejecting it a MUST: the audience is what stops one recipient from becoming an impersonator of the client at every other endpoint.
  • The same client authenticates for months, then fails intermittently after traffic doubles. What is a likely cause?
    A `jti` that is not actually unique. If the client derives it from something that can repeat under concurrency, two in-flight assertions carry the same identifier and the server rejects the second as a replay. The failure tracks load rather than time, which is the tell.
  • How is client_secret_jwt different, given both send an assertion?
    `client_secret_jwt` authenticates the assertion with a message authentication code keyed by the `client_secret`, so the secret stays off the wire but both parties still hold the same symmetric value. `private_key_jwt` signs with a private key the server never has, so the server's store holds only a public key.
  • What has to happen before a client can rotate its signing key without an outage?
    Publish both keys in the client's key set with distinct key identifiers and keep signing with the old one until the server has certainly refreshed its copy, then switch and retire the old key after a further window. Retiring immediately fails for as long as the server's cached key set is stale.

saying these in an interview costs you the question

  • Thinks the private key is sent in the assertion
  • Sets sub to the resource owner rather than the client_id
  • Believes any aud value works as long as the signature verifies
  • Says client_secret_jwt and private_key_jwt are the same trade
  • Omits jti and assumes a short exp prevents replay
  • Claims RFC 7591 defines the private_key_jwt value