# How to authenticate to the token endpoint with a client assertion

> Build a short-lived, single-use JWT, sign it with the client's private key, post it as client_assertion in place of a secret, and watch a reused one be refused.

Source: https://voxgig.com/howto/authenticate-with-a-jwt-client-assertion

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript, python, go
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Sign a JWT with the client's private key: `iss` and `sub` are the client id, `aud` names the authorization server, `exp` is a minute away, and `jti` is fresh. Post it as `client_assertion` with the jwt-bearer `client_assertion_type` in place of a secret, once. The server verifies it against your registered public key and remembers the `jti`, so a retry has to mint a fresh assertion rather than resend the old one.

---
## You will need

Node 22 or later, Python 3.13 or later, and Go 1.24 or later. An OAuth client registered at the
authorization server with a public key or a `jwks_uri`, and a token endpoint that advertises
`private_key_jwt` as a client authentication method. Verified 2026-10-21 against Node 22.22.0,
Python 3.13.16, Go 1.24.7, `jose` 6.2.12, PyJWT 2.15.1 with cryptography 50.0.2, and
`golang-jwt/jwt` v5.3.1. The token endpoint is a stand-in in `as.mjs` that applies the checks
[RFC 7523](https://www.rfc-editor.org/rfc/rfc7523.html#section-3) lists and the defaults Keycloak
ships, so every command runs offline. Each producer generates a key pair per run and hands the
public half over with the assertion, which is what registration or a `jwks_uri` does once in
production.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [client_secret_post](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1) | A server that offers nothing else, or a client that cannot hold a private key | A shared secret stored on both sides, rotated by changing both, and a method RFC 6749 does not recommend | The server lists `private_key_jwt`, or the secret has ever had to be rotated in a hurry |
| [golang-jwt](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) | A Go client, with `RegisteredClaims` carrying `iss`, `sub`, `aud`, `exp`, and `jti` | No JWK export, so publishing the public key is code you write, and key loading from PEM is yours too | The client is not written in Go |
| [`jose` SignJWT](https://github.com/panva/jose/blob/main/docs/jwt/sign/classes/SignJWT.md) | A Node or browser-runtime client, with one builder for the claims and a JWK export for the key | A dependency, and an `exp` you set yourself, because nothing in the builder makes an assertion short-lived | The client is not JavaScript |
| [mTLS client authentication](https://www.rfc-editor.org/rfc/rfc8705.html) | A client that already holds a certificate, and a server that terminates TLS itself | A PKI to run, a server that must see the client certificate, and a proxy in the path that usually hides it | TLS terminates at a load balancer you do not control |
| [PyJWT](https://pyjwt.readthedocs.io/en/stable/usage.html) | A Python client, with `jwt.encode` over a dict of claims and `to_jwk` for the public key | The `cryptography` extra for any asymmetric algorithm, and claims you assemble by hand | The client is not Python |

The three libraries produce the same assertion, so the choice between them is the language of
the client. The comparison that matters is between the three authentication methods. A secret is
shared, and rotates on both sides. A private key never leaves the client and rotates by publishing
a key under a fresh `kid`, at the price of key storage and a clock that is right to the minute.
Mutual TLS moves the problem into the TLS stack, a help when you own that stack and a wall when a
proxy terminates it.

## Build the assertion with the claims RFC 7523 fixes

Section 3 of RFC 7523 fixes the claims. `iss` identifies the issuer, and for client
authentication `sub` is the client id. `aud` identifies the authorization server, and `exp` is
required. `jti` is optional, and the server may keep the values it has seen to refuse a replay.
[OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
tightens two of those for `private_key_jwt`: `iss` is the client id as well, and `jti` is
required and may be used once. The lifetime here is sixty seconds, which is the most Keycloak
accepts by default.

```js title="assertion.mjs"
export async function buildAssertion({ clientId, audience, key, lifetime = 60, now = Math.floor(Date.now() / 1000) }) {
  return new SignJWT({})
    .setProtectedHeader({ alg: 'ES256', kid: key.kid })
    .setIssuer(clientId)
    .setSubject(clientId)
    .setAudience(audience)
    .setIssuedAt(now)
    .setExpirationTime(now + lifetime)
    .setJti(randomUUID())
    .sign(key.privateKey)
}
```

The request puts the assertion where the secret would have gone.
[RFC 7521](https://www.rfc-editor.org/rfc/rfc7521.html#section-4.2) defines the two parameters,
and leaves `client_id` optional because the assertion's subject already names the client.[^1]

```js title="assertion.mjs"
export async function requestToken({ tokenUrl, assertion, scope, grantType = 'client_credentials' }) {
  const body = new URLSearchParams({ grant_type: grantType, client_assertion_type: ASSERTION_TYPE, client_assertion: assertion })
  if (scope) body.set('scope', scope)
  const res = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
    body: body.toString(),
  })
  // A proxy in front of the endpoint can answer a 5xx with an HTML page, so the
  // body is parsed only when it is JSON, and a retry still sees the status.
  const json = (res.headers.get('content-type') ?? '').includes('json')
  return { status: res.status, body: json ? await res.json() : null }
}
```

## Verify it the way the server will

The stand-in runs the checks in the order Keycloak's
[client validator](https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java)
does. The two parameters come first, then the client named by `sub`, the signature against that
client's registered key, the audience, the times, and reuse. Keycloak accepts its issuer URL or
its token endpoint URL as the audience, allows fifteen seconds of clock skew, caps the lifetime at
sixty seconds, and permits no reuse.[^2]

```js title="as.mjs"
const client = clients.get(claims.sub)
if (!client) return refuse(res, 'sub does not name a registered client')
if (claims.iss !== claims.sub) return refuse(res, 'iss must equal sub')
if (Array.isArray(claims.aud) && claims.aud.length > 1) return refuse(res, 'an assertion carries one audience')

let payload
try {
  ;({ payload } = await jwtVerify(assertion, client.keys, {
    issuer: claims.sub,
    subject: claims.sub,
    audience: 'issuer' === audiences ? [ISSUER] : [ISSUER, tokenUrl],
    clockTolerance: skew,
    requiredClaims: ['exp', 'jti'],
  }))
} catch (err) {
  return refuse(res, reason(err))
}
const now = Math.floor(Date.now() / 1000)
// An iat set ahead would carry exp past the cap below, so an assertion issued
// in the future, beyond the skew, is refused, as Keycloak refuses it.
if (undefined !== payload.iat && payload.iat - skew > now) return refuse(res, 'iat is in the future')
if (payload.exp - (payload.iat ?? now) > maxLifetime) return refuse(res, `exp is more than ${maxLifetime} s after iat`)

// The replay check. A jti is remembered until the assertion it belongs to
// would have expired anyway, which is RFC 7523's suggestion.
for (const [jti, exp] of used) if (exp + skew < now) used.delete(jti)
if (used.has(payload.jti)) return refuse(res, 'the assertion was presented before: its jti has been used')
```

The demo builds one assertion, reads its claims back, presents it twice, and then asks two
differently configured servers about the audience.

```bash
node demo.mjs
```

```text output
the assertion
  header  alg ES256  kid node-1
  iss     svc-fulfil
  sub     svc-fulfil
  aud     https://auth.example.com
  exp     60 s after iat
  jti     36 characters, fresh per assertion
the token endpoint
  first presentation                       200  access token, scope orders:read
  the same assertion again                 400  invalid_client: the assertion was presented before: its jti has been used
  scope the client was not granted         400  invalid_scope: the client is not granted orders:admin
which aud the server accepts
  Keycloak-like: issuer or token URL       aud = issuer 200   aud = token endpoint URL 200
  rfc7523bis: issuer only                  aud = issuer 200   aud = token endpoint URL 400  invalid_client: the aud claim is not acceptable
```

The second presentation fails on `jti` alone: the signature, the audience, and the expiry are
all still good. The scope refusal is different in kind, an `invalid_scope` from the grant after
the client has authenticated. The last block is the disagreement the title of this section
promises. A server that follows
[`draft-ietf-oauth-rfc7523bis`](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/)
takes the issuer identifier as the only audience, and refuses the token endpoint URL that OpenID
Connect Core recommends.[^3] Set `aud` to the issuer identifier, which every server in that list
accepts.

## Produce the same assertion in the other two languages

PyJWT takes the claims as a dict and the header's `kid` as an argument, and its `ECAlgorithm`
renders the public key as a JWK.

```py title="assertion.py"
claims = {
    "iss": client_id,
    "sub": client_id,
    "aud": audience,
    "iat": now,
    "exp": now + 60,
    "jti": str(uuid.uuid4()),
}
assertion = jwt.encode(claims, key, algorithm="ES256", headers={"kid": "py-1"})
```

golang-jwt carries the same five claims in `RegisteredClaims`, with `Audience` a list and `ID`
the `jti`. The header map takes the `kid`, and the JWK is rendered by hand, because the module
exports no key format.

```go title="go/main.go"
now := time.Now()
claims := jwt.RegisteredClaims{
  Issuer:    clientID,
  Subject:   clientID,
  Audience:  jwt.ClaimStrings{audience},
  IssuedAt:  jwt.NewNumericDate(now),
  ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
  ID:        jti(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = "go-1"
assertion, err := token.SignedString(key)
```

The cross-check runs both producers, registers each public key with the stand-in, and presents
each assertion twice.

```bash
node crosscheck.mjs
```

```text output
node    jose SignJWT        iss=sub=svc-fulfil aud=https://auth.example.com exp-iat=60s alg=ES256 kid=node-1
                            first presentation 200, same assertion again 400 invalid_client
python  PyJWT               iss=sub=svc-fulfil aud=https://auth.example.com exp-iat=60s alg=ES256 kid=py-1
                            first presentation 200, same assertion again 400 invalid_client
go      golang-jwt/jwt/v5   iss=sub=svc-fulfil aud=https://auth.example.com exp-iat=60s alg=ES256 kid=go-1
                            first presentation 200, same assertion again 400 invalid_client
```

One verifier accepts the assertions of all three libraries, because they produce the same wire
format. The differences are in how much each library does for you: `jose` exports the key, PyJWT exports it through an algorithm class, and Go leaves
the base64url coordinates to the caller.

## Check it worked

The test suite pins the claims, the acceptance, and each refusal on the page against the running
stand-in.

```js title="assertion.test.mjs"
test('the same assertion is refused the second time', async () => {
  const a = await mint()
  assert.equal((await present(a)).status, 200)
  const r = await present(a)
  assert.equal(r.status, 400)
  assert.equal(r.body.error, 'invalid_client')
  assert.match(r.body.error_description, /jti has been used/)
})
```

```bash
node --test assertion.test.mjs
```

```text output
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 241.622137
```

## When it goes wrong

A retry fails with `invalid_client` after the first attempt failed with a 503. The retry resent
the assertion, and the server had already recorded its `jti` before the outage struck.

```bash
node pitfall.mjs
```

```text output
one assertion, resent after a 503
  attempt 1  503  temporarily_unavailable
  attempt 2  400  invalid_client: the assertion was presented before: its jti has been used
a fresh assertion per attempt, after a 503
  attempt 2  200  access token issued
```

Mint inside the retry loop, as `requestTokenWithRetry` does. A signature per attempt costs
nothing worth measuring, and a token request happens once per access token lifetime.

The server refuses the audience. Either you sent the token endpoint URL to a server that wants
the issuer identifier, or the reverse. Read the server's discovery document for its `issuer` and
send that, and check its documentation if it still refuses, because the three specifications in
play give three different answers.

The assertion is refused as expired, or as not yet valid, by a server whose clock you trust. The
client's clock is off by more than the server's skew allowance, fifteen seconds in Keycloak. Fix
the clock; a longer lifetime would widen the replay window instead.

The signature does not verify after a key rotation. The server still holds the old key, or the
assertion's `kid` names a key the server never fetched. Publish the fresh key beside the old one
before you sign with it, and give the server time to re-read the `jwks_uri`.

## When not to do this

Do not ship the private key with the service's code or image. The reason to prefer an assertion
over a secret is that the key never travels, so a key copied into a repository or a build
artifact has given that reason away. Load it from a vault or a hardware module at startup.

Do not reuse an assertion to save a signature. The server refuses the second presentation, and
the first one bought a token that lives for minutes, so there is no request to save it on.

Do not set a lifetime longer than the server's maximum to survive clock drift. Keycloak caps an
assertion at sixty seconds, and a longer `exp` is a longer window in which a captured assertion is
good. Fix the clock instead.

Do not add client assertions where mutual TLS already reaches the authorization server. RFC 8705
authenticates the client with the certificate the connection already carries, and binds the
access token to it, which an assertion cannot do.

## Related how-tos

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

- [Rotate API keys without breaking your clients](/howto/rotate-api-keys-without-breaking-clients)

- [Get a client credentials token for calls between services](/howto/client-credentials-for-service-to-service-calls)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, Go 1.24.7, `jose` 6.2.12, PyJWT 2.15.1,
cryptography 50.0.2, and `golang-jwt/jwt` v5.3.1. Every output block is what the command preceding
it printed. The authorization server is the stand-in in `as.mjs`, on the loopback interface, built
to the rules of RFC 7523 and the defaults read from Keycloak's source. Keycloak itself was not run.

[^1]: Section 4.2 of [RFC 7521](https://www.rfc-editor.org/rfc/rfc7521.html#section-4.2) calls
`client_id` unnecessary for client assertion authentication because the client is identified by
the subject of the assertion. If it is sent, it must identify the same client. The section also
suggests the form `urn:ietf:params:oauth:client-assertion-type:*` for the type parameter,
to be requested with the template in RFC 6755. The credential's type is a URN obtained by filling
in a form.

[^2]: Keycloak's
[validator](https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java)
returns five acceptable audiences: the issuer, the token endpoint, the introspection endpoint,
the pushed authorization request endpoint, and the back-channel authentication endpoint. Its clock skew is the constant 15, and `isReusePermitted` returns false. The
[administration guide](https://www.keycloak.org/docs/latest/server_admin/index.html#_client-credentials)
gives the maximum lifetime as 60 seconds by default and adds that tokens should be issued right
before the authentication, which is the lifetime this page uses.

[^3]: Three documents name the audience of a client assertion, and each uses a different verb.
[RFC 7523](https://www.rfc-editor.org/rfc/rfc7523.html#section-3) says the token endpoint URL MAY
be used. [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
says the audience SHOULD be the URL of the token endpoint.
[`draft-ietf-oauth-rfc7523bis`](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis/),
at revision 11 and in final review at the RFC Editor on the day this page was written, says the
issuer identifier is the sole value. The token endpoint URL MUST NOT be used. A client that
follows the oldest document may send the token endpoint URL, which a server that follows the newest
must refuse.