How-to › Authenticate and authorize calls

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.

Audience
API consumer
Level
intermediate
Topic
Sign requests and use mTLS
Languages
TypeScript, JavaScript, Python and Go
Verified

Your service authenticates to the token endpoint with a client secret, so the same string sits in a vault, in three deployment pipelines, and in the authorization server’s database. Rotating it means a coordinated change on both sides, and a copy that leaks is a working credential until somebody notices.

What you get

You will end up with a client that proves its identity with a signed, single-use JWT instead of a secret, in Node, Python, and Go. The stand-in token endpoint refuses a reused assertion. This is for you if your client holds a client secret.

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 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
client_secret_postA server that offers nothing else, or a client that cannot hold a private keyA shared secret stored on both sides, rotated by changing both, and a method RFC 6749 does not recommendThe server lists private_key_jwt, or the secret has ever had to be rotated in a hurry
golang-jwtA Go client, with RegisteredClaims carrying iss, sub, aud, exp, and jtiNo JWK export, so publishing the public key is code you write, and key loading from PEM is yours tooThe client is not written in Go
jose SignJWTA Node or browser-runtime client, with one builder for the claims and a JWK export for the keyA dependency, and an exp you set yourself, because nothing in the builder makes an assertion short-livedThe client is not JavaScript
mTLS client authenticationA client that already holds a certificate, and a server that terminates TLS itselfA PKI to run, a server that must see the client certificate, and a proxy in the path that usually hides itTLS terminates at a load balancer you do not control
PyJWTA Python client, with jwt.encode over a dict of claims and to_jwk for the public keyThe cryptography extra for any asymmetric algorithm, and claims you assemble by handThe 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 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.

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 defines the two parameters, and leaves client_id optional because the assertion’s subject already names the client.1

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 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

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.

node demo.mjs
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 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.

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.

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.

node crosscheck.mjs
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.

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/)
})
node --test assertion.test.mjs
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.

node pitfall.mjs
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.

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.

Footnotes

  1. Section 4.2 of RFC 7521 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. ↩︎ Back to text

  2. Keycloak’s validator 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 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. ↩︎ Back to text

  3. Three documents name the audience of a client assertion, and each uses a different verb. RFC 7523 says the token endpoint URL MAY be used. OpenID Connect Core says the audience SHOULD be the URL of the token endpoint. 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.