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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| client_secret_post | 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 | 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 | 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 | 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 | 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
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.
Related how-tos
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
-
Section 4.2 of RFC 7521 calls
client_idunnecessary 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 formurn: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 -
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
isReusePermittedreturns 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 -
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