Keycloak Client Authentication Without Secrets: Signed JWT (private_key_jwt)

Guilliano MolaireGuilliano Molaire8 min read

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

Signed JWT client authentication flow The service signs a short JWT with its private key and sends it to the Keycloak token endpoint. Keycloak verifies the signature with the registered public key and returns an access token. Your service Holds the private key (from your PKI or a generated key pair) 1. Signs a JWT, valid 60 s Keycloak Stores only the public key (certificate or JWKS URL) 2. Checks signature, iss, sub, aud, exp and jti client_assertion = signed JWT 3. access_token returned JWT token The private key never leaves your service. Keycloak never sees anything it could reuse.
Each token request carries a new assertion. A captured assertion cannot be replayed: its 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

  1. 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.
  2. 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.
  3. 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 kid it 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.

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.

Key rotation with a JWKS URL The service publishes both the old and new public keys in its JWKS, switches signing to the new key, then removes the old key. Keycloak picks the key by kid at each step. 1. Publish new key 2. Sign with new key 3. Remove old key key-2026-04 key-2026-10 JWKS lists both keys. Still signing with the old one. key-2026-04 key-2026-10 Assertions carry the new kid. Keycloak finds it in the JWKS. key-2026-10 Old key gone. No change needed in Keycloak.
With a JWKS URL, rotation is a change on the service side only. With an imported certificate there is no overlap: import the new certificate and switch signing in the same step.

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.
  • kid depends on how you registered the key. With a JWKS URL, send the kid of the signing key so Keycloak can tell old and new keys apart. With an imported certificate, leave kid out: Keycloak derives its own key ID from the certificate and rejects an assertion whose kid does not match it. (The Admin API lets you set that ID yourself through the client attribute jwt.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.

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.

Guilliano Molaire
Written by
Founder, Skycloak

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

© 2026 Skycloak. All Rights Reserved. Design by Yasser SolimanKeycloak™ is a trademark of the Linux Foundation. Skycloak is not affiliated with the Keycloak project.