Short answer: set the client’s authenticator to Signed JWT in Keycloak, register the client’s public key (a certificate PEM or a JWKS URL), and have the client send a short JWT signed with its private key instead of a client secret. Nothing secret is stored in Keycloak, the private key never leaves the client, and it works for the client credentials grant and for standard token exchange.
This is the private_key_jwt method from OpenID Connect Core section 9 and RFC 7523. Teams that already issue keys and certificates to their services from an internal PKI can reuse those keys and drop shared secrets entirely.
Why move off client secrets
A client secret is a shared password. Both sides hold it, it travels in every token request, and it tends to end up in CI variables, config files and chat logs. Rotating it means changing both sides at the same moment.
With Signed JWT, Keycloak only holds a public key. The client proves who it is by signing a fresh, short-lived assertion. If Keycloak’s database leaked, there would be nothing in it an attacker could use to impersonate the client.
| Client secret | Signed JWT (private_key_jwt) | mTLS client certificate | |
|---|---|---|---|
| What Keycloak stores | The secret itself | A public key or a JWKS URL | Trust in a CA or a certificate subject |
| What crosses the network | The secret, on every request | A signed, single-use assertion | Nothing extra; proof is in the TLS handshake |
| Replay of a captured request | Possible until the secret rotates | Blocked: each assertion has a unique jti and a short expiry |
Not possible without the private key |
| Rotation | Change both sides together | Publish a new key in the JWKS; old and new overlap | Reissue certificates from the CA |
| Needs TLS to reach Keycloak end to end | No | No | Yes, or a proxy that forwards the certificate |
How it works
jti is single use and it expires within seconds.The assertion is a normal JWT with these claims:
| Claim | Value |
|---|---|
iss |
The client ID |
sub |
The client ID |
aud |
The realm issuer URL, for example https://auth.example.com/realms/acme |
jti |
A unique random value per request |
exp |
A few seconds to a minute from now |
iat |
Now |
Three details matter in Keycloak 26. aud must be a single string, not an array: since 26.2 Keycloak refuses multiple audiences by default, accepts the realm issuer URL or the token endpoint URL, and recommends the issuer because that is where the OpenID Connect specification is heading. exp and jti are required. Keycloak remembers every jti until its assertion expires and rejects a repeat, allows 15 seconds of clock skew, and refuses an assertion once iat is older than the client’s Max expiration setting (60 seconds by default), whatever exp says.
The JWT header carries alg (for example RS256 or ES256) and, when the client publishes a JWKS, kid, the key ID Keycloak uses to pick the right public key. The rotation section below explains when to leave kid out.
Set it up in Keycloak
- Open Clients, select the client, and check that Client authentication is On under Capability config on the Settings tab, since Signed JWT is for confidential clients. In the same section, tick Service account roles if the client calls APIs on its own behalf (that is the client credentials grant), and Standard Token Exchange if it will exchange tokens.
- On the Credentials tab, set Client Authenticator to Signed JWT. Optionally pin the Signature algorithm (the default accepts any asymmetric algorithm) so nothing weaker is accepted, and adjust Max expiration if 60 seconds does not suit your clocks.
- On the Keys tab, register the public key in one of two ways:
- JWKS URL (recommended): switch Use JWKS URL on and enter the URL where the service publishes its public keys. Keycloak fetches the keys on first use and again when it sees a
kidit does not know, so rotation needs no change in Keycloak. - Import a certificate: leave Use JWKS URL off, click Import, choose Certificate PEM as the Archive format (Public Key PEM and JSON Web Key Set also work), and upload the certificate your PKI issued for this service.
- JWKS URL (recommended): switch Use JWKS URL on and enter the URL where the service publishes its public keys. Keycloak fetches the keys on first use and again when it sees a
Keycloak can also generate a key pair for you (Generate RSA keys on the same tab), but then the private key has passed through Keycloak once, which gives away part of the benefit. Bring your own key when you can.
The same settings are available through the Admin REST API and the Keycloak Terraform provider, so you can manage hundreds of clients as code.
Request a token
Generate the assertion and send it in place of a secret. Python, using PyJWT:
# pip install pyjwt cryptography requests
import time, uuid, jwt, requests
ISSUER = "https://auth.example.com/realms/acme"
TOKEN_URL = f"{ISSUER}/protocol/openid-connect/token"
CLIENT_ID = "billing-service"
with open("billing-service.key", "rb") as f:
private_key = f.read()
now = int(time.time())
assertion = jwt.encode(
{"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": ISSUER,
"jti": str(uuid.uuid4()), "iat": now, "exp": now + 60},
private_key,
algorithm="RS256",
# The kid of this key in your JWKS. Leave headers out if you imported a certificate.
headers={"kid": "key-2026-10"},
)
resp = requests.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
})
resp.raise_for_status()
access_token = resp.json()["access_token"]
The same request with curl, once you have an assertion:
curl -s "$ISSUER/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d client_id=billing-service \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d client_assertion="$ASSERTION"
Most OAuth libraries do this for you. Look for the private_key_jwt client authentication method in Spring Security, PrivateKeyJWT in the Nimbus OAuth 2.0 SDK, PrivateKeyJwt in oauth4webapi or PrivateKeyJWT in Authlib, and pass them the key instead of a secret.
Talk through your identity setup with us
Book a demo and we will walk you through Skycloak with the sign-in, SSO and migration questions your team is working on.
Token exchange works the same way
Standard token exchange (RFC 8693, supported in Keycloak since 26.2) requires the requesting client to be confidential and authenticated with whatever method it has configured. Signed JWT counts, so you swap the secret for an assertion and keep the rest. The client also needs Standard Token Exchange ticked under Capability config, as in step 1 above.
curl -s "$ISSUER/protocol/openid-connect/token" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d client_id=api-gateway \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d client_assertion="$ASSERTION" \
-d subject_token="$INCOMING_ACCESS_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d audience=orders-api
Each assertion is single use, so generate a new one for every token request. For the exchange itself, including scopes and audiences, see our practical guide to Keycloak token exchange.
Plan for key rotation
Keycloak trusts the key you registered, not the certificate authority that issued it. It checks the signature against that public key; it does not walk a certificate chain back to your CA, and it does not check revocation lists.
What that means in practice:
- Renewing a certificate with the same key pair changes nothing for Keycloak.
- Re-keying means the new public key must reach Keycloak: publish both keys in the service’s JWKS during the overlap, as above, or re-import the certificate.
- Revoking a service is done in Keycloak, not with a CRL: remove its key, or disable the client.
kiddepends on how you registered the key. With a JWKS URL, send thekidof the signing key so Keycloak can tell old and new keys apart. With an imported certificate, leavekidout: Keycloak derives its own key ID from the certificate and rejects an assertion whosekiddoes not match it. (The Admin API lets you set that ID yourself through the client attributejwt.credential.kid; the console does not expose it.)
If you need Keycloak itself to validate a full chain against your CA, or certificate-bound access tokens (RFC 8705), that is mTLS client authentication. It requires the client’s TLS connection, or a trusted proxy that forwards the verified certificate, to reach Keycloak, which is an infrastructure decision rather than a client setting.
Signed JWT on Skycloak
Signed JWT is a standard Keycloak client setting, so it works on any Skycloak cluster with no extra setup, from the admin console, the Admin API or Terraform. TLS terminates at our edge network before traffic reaches your cluster, so X.509 client certificate authentication is not available on Skycloak, and Signed JWT is the way to authenticate clients with your own keys today.
FAQ
Is Signed JWT the same as “Signed JWT with Client Secret”?
No. Keycloak also offers “Signed JWT with Client Secret” (client_secret_jwt), which signs the assertion with HMAC using a shared secret. It avoids sending the secret over the wire, but Keycloak still stores it. Use plain Signed JWT to keep only public keys in Keycloak.
Can I reuse the certificates my internal CA already issues? Yes. Sign the assertion with the private key behind the certificate and register the certificate (or a JWKS built from it) on the client. Keycloak uses the public key in it and ignores the chain.
Which algorithms are supported? Any asymmetric algorithm Keycloak can verify: RSA (RS256, RS384, RS512, PS256, PS384, PS512), ECDSA (ES256, ES384, ES512) and EdDSA. HMAC algorithms are refused for plain Signed JWT. Pin one in the client’s credentials settings.
What expiry should the assertion have?
Keep it short: 30 to 60 seconds is typical. Keycloak allows 15 seconds of clock skew and refuses an assertion once it is older than the client’s Max expiration (60 seconds by default), whatever exp says. It also records every jti until the assertion expires and rejects a repeat, so a captured assertion is useless even inside that window.
Does it work with the client credentials grant and with token exchange? Yes, both. Any request where the client authenticates to the token endpoint can use an assertion instead of a secret.
If you would rather not run Keycloak yourself, Skycloak provides managed Keycloak with Signed JWT available on every cluster. Book a demo to see it on your own clients. For service-to-service patterns more broadly, see our guide to machine-to-machine authentication with Keycloak.
