Skip to content

Stop storing client private keys in the database - #51612

Open
jimmychakkalakal wants to merge 1 commit into
keycloak:mainfrom
jimmychakkalakal:weakness/49239-Stop-storing-client-private-keys-in-database
Open

Stop storing client private keys in the database#51612
jimmychakkalakal wants to merge 1 commit into
keycloak:mainfrom
jimmychakkalakal:weakness/49239-Stop-storing-client-private-keys-in-database

Conversation

@jimmychakkalakal

Copy link
Copy Markdown
Contributor

The generate endpoint stored client private keys as clear-text client attributes. The server never uses these keys for any cryptographic operation — it only needs the public key/certificate to verify signature. This change:

  • Stops persisting private keys in generate, upload, and SAML client creation endpoints
  • Strips private keys from getKeyInfo responses
  • Deprecates the generate, download, and generate-and-download endpoints
  • Updates admin UI to reflect that private keys are downloaded once and not stored
  • Updates documentation with guidance to generate keys externally and examples for converting keystores to JWK format for JWKS_URL usage

Closes #49239

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Stops storing client private keys server-side while deprecating server-managed key generation.

Changes:

  • Persists only public certificates/keys and sanitizes key-info responses.
  • Deprecates key generation and download APIs.
  • Updates admin UI, tests, installation templates, and documentation.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/base/.../CredentialsTest.java Updates certificate persistence tests.
services/.../CertificateInfoHelper.java Omits private keys from responses.
services/.../ClientAttributeCertificateResource.java Sanitizes storage and deprecates endpoints.
services/.../SamlProtocolFactory.java Stops storing generated SAML private keys.
services/.../KeycloakSamlSubsystemCliInstallation.java Updates missing-key guidance.
services/.../KeycloakSamlClientInstallation.java Updates installation placeholders.
js/libs/.../clients.ts Deprecates admin-client methods.
js/apps/.../SamlKeysDialog.tsx Downloads generated keys once.
js/apps/.../SamlKeys.tsx Refreshes public key information.
js/apps/.../messages_en.properties Clarifies private-key handling.
integration/.../ClientAttributeCertificateResource.java Deprecates Java admin APIs.
docs/.../proc-creating-saml-client.adoc Documents SAML key ownership.
docs/.../con-confidential-client-credentials.adoc Adds external generation and JWKS guidance.
Suppressed comments (4)

services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java:186

  • Although the client attributes receive infoForStorage, the original upload representation (including an extracted private key) is still written to the admin event at line 188. Use the sanitized representation for the event as well so keystore uploads do not persist private keys in event storage.
            CertificateRepresentation infoForStorage = new CertificateRepresentation();
            infoForStorage.setCertificate(info.getCertificate());
            infoForStorage.setPublicKey(info.getPublicKey());
            infoForStorage.setKid(info.getKid());
            CertificateInfoHelper.updateClientModelCertificateInfo(client, infoForStorage, attributePrefix);

js/libs/keycloak-admin-client/src/resources/clients.ts:1015

  • The newly deprecated method is still called without suppression in ExportSamlKeyDialog.tsx:36 and clients.spec.ts:980. With the workspace's no-deprecated rule, those callers cause lint errors.
  /**
   * @deprecated Private keys are no longer stored on the server. The downloaded
   *             keystore will only contain the certificate.
   */

js/libs/keycloak-admin-client/src/resources/clients.ts:1032

  • Keys.tsx:87 and clients.spec.ts:955 still call this newly deprecated method without suppressing @typescript-eslint/no-deprecated, so the strict TypeScript lint configuration will reject them.
  /**
   * @deprecated Clients should generate their own key pairs and upload only the
   *             public key/certificate.
   */

tests/base/src/test/java/org/keycloak/tests/admin/client/CredentialsTest.java:147

  • getKeyInfo() now strips private keys independently of storage, so this assertion would pass even if generate() still persisted the private-key attribute. Verify the underlying client model/database attribute directly to cover the security requirement.
        // But server should not have stored it
        CertificateRepresentation certFromGet = certRsc.getKeyInfo();
        assertNull(certFromGet.getPrivateKey(), "private key should not be stored on server");

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread js/libs/keycloak-admin-client/src/resources/clients.ts

@keycloak-github-bot keycloak-github-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

@keycloak-github-bot

Copy link
Copy Markdown

Unreported flaky test detected

If the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR.

org.keycloak.testsuite.webauthn.WebAuthnPropertyTest#timeout

Keycloak CI - WebAuthn IT

org.openqa.selenium.WebDriverException: 
unknown error: unhandled inspector error: {"code":-32000,"message":"Node with given id does not belong to the document"}
  (Session info: chrome=150.0.7871.128)
Build info: version: '4.28.1', revision: '73f5ad48a2'
System info: os.name: 'Linux', os.arch: 'amd64', os.version: '6.17.0-1020-azure', java.version: '25.0.3'
...

Report flaky test

@jimmychakkalakal
jimmychakkalakal force-pushed the weakness/49239-Stop-storing-client-private-keys-in-database branch from 5af0811 to 755617e Compare August 11, 2026 10:44
@jimmychakkalakal
jimmychakkalakal requested a balanced review from Copilot August 11, 2026 10:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comment thread services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java Outdated
@jimmychakkalakal
jimmychakkalakal force-pushed the weakness/49239-Stop-storing-client-private-keys-in-database branch from 755617e to 9237ba5 Compare August 11, 2026 13:34
@jimmychakkalakal
jimmychakkalakal requested a balanced review from Copilot August 11, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (4)

services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java:204

  • This Javadoc still says the download contains a private key, contradicting the newly updated operation description and the implementation, which now obtains a public-only representation. Update it to state that the keystore contains only the public certificate.
     * @deprecated Clients should manage their own private keys.

js/apps/admin-ui/src/clients/keys/ExportSamlKeyDialog.tsx:37

  • This action is now misleading because downloadKey returns a certificate-only keystore, while the dialog is still titled “Export SAML Keys” and KeyForm requires a key password described as the private-key password. Operators may mistake this export for a private-key backup; rename it as certificate export and remove the key-password field, or remove the deprecated action.
      // eslint-disable-next-line @typescript-eslint/no-deprecated
      const keyStore = await adminClient.clients.downloadKey(

integration/admin-client-core/src/main/java/org/keycloak/admin/client/resource/ClientAttributeCertificateResource.java:90

  • The new deprecation text conflicts with the method description above, which still promises a keystore containing the private key. Update the public admin-client Javadoc to state that only the certificate is returned so callers do not treat this endpoint as private-key recovery.
     * @deprecated Clients should manage their own private keys.

services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java:173

  • The updated JWKS upload test uses a public-only fixture, so it does not exercise the security behavior added here. Add coverage with private RSA/EC (and ideally symmetric/OKP) parameters that verifies both the stored JWKS and getKeyInfo() output omit every entry in this set.
    private static final Set<String> JWK_PRIVATE_KEY_PARAMS = Set.of("d", "p", "q", "dp", "dq", "qi", "oth", "k");

    public static String stripPrivateKeyParams(String jwks) {

@jimmychakkalakal
jimmychakkalakal force-pushed the weakness/49239-Stop-storing-client-private-keys-in-database branch 2 times, most recently from 60009a1 to 36fc63c Compare August 12, 2026 13:36
The generate endpoint stored client private keys as clear-text client attributes. The server never uses these keys for any cryptographic operation — it only needs the public key/certificate to verify signature. This change:
  - Stops persisting private keys in generate, upload,
    and SAML client creation endpoints
  - Strips private keys from getKeyInfo responses
  - Deprecates the generate, download, and
    generate-and-download endpoints
  - Updates admin UI to reflect that private keys are
    downloaded once and not stored
  - Updates documentation with guidance to generate keys
    externally and examples for converting keystores to
    JWK format for JWKS_URL usage

Closes keycloak#49239

Signed-off-by: Jimmy Chakkalakal <jimmy.chakkalakal@ibm.com>
@jimmychakkalakal
jimmychakkalakal force-pushed the weakness/49239-Stop-storing-client-private-keys-in-database branch from 36fc63c to 180ce8c Compare August 12, 2026 13:37
@jimmychakkalakal
jimmychakkalakal marked this pull request as ready for review August 12, 2026 17:32
@jimmychakkalakal
jimmychakkalakal requested a review from a team as a code owner August 12, 2026 17:32
Copilot AI review requested due to automatic review settings August 12, 2026 17:32
@jimmychakkalakal
jimmychakkalakal requested review from a team as code owners August 12, 2026 17:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java:557

  • This assertion verifies equivalence after re-serialization, but it doesn’t validate the new security behavior (that private JWK parameters are removed before storing). Add an assertion here that the stored JWKS_STRING (or the parsed actual keys) does not contain private parameters like d/p/q/dp/dq/qi/oth/k, so the test directly covers the private-key stripping requirement.
            JSONWebKeySet expected = JsonSerialization.readValue(jwks, JSONWebKeySet.class);
            JSONWebKeySet actual = JsonSerialization.readValue(client.getAttributes().get(OIDCConfigAttributes.JWKS_STRING), JSONWebKeySet.class);
            Assertions.assertEquals(JsonSerialization.writeValueAsString(expected), JsonSerialization.writeValueAsString(actual));

tests/base/src/test/java/org/keycloak/tests/admin/client/CredentialsTest.java:156

  • adminEvents.poll() can return null, which would cause a NullPointerException at event.getRepresentation() and produce a less actionable test failure. Add an explicit assertNotNull(event, ...) (and potentially assertNotNull(event.getRepresentation(), ...)) before accessing it so failures clearly indicate the missing admin event.
        AdminEventRepresentation event = adminEvents.poll();
        assertFalse(event.getRepresentation().contains(cert.getPrivateKey()), "admin event should not contain private key");
        CertificateRepresentation expectedEventRep = new CertificateRepresentation();
        expectedEventRep.setCertificate(cert.getCertificate());
        AdminEventAssertion.assertEvent(event, OperationType.ACTION, AdminEventPaths.clientCertificateGenerateSecretPath(accountClient.toRepresentation().getId(), "jwt.credential"), expectedEventRep, ResourceType.CLIENT);

Comment on lines 181 to +195
if (OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && info.getJwks() != null) {
CertificateInfoHelper.updateClientModelJwksString(client, attributePrefix, info.getJwks());
} else {
CertificateInfoHelper.updateClientModelCertificateInfo(client, info, attributePrefix);
CertificateRepresentation infoForStorage = new CertificateRepresentation();
infoForStorage.setCertificate(info.getCertificate());
infoForStorage.setPublicKey(info.getPublicKey());
infoForStorage.setKid(info.getKid());
CertificateInfoHelper.updateClientModelCertificateInfo(client, infoForStorage, attributePrefix);
}
adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(info).success();
CertificateRepresentation sanitized = new CertificateRepresentation();
sanitized.setCertificate(info.getCertificate());
sanitized.setPublicKey(info.getPublicKey());
sanitized.setKid(info.getKid());
sanitized.setJwks(info.getJwks());
adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(sanitized).success();
Comment on lines +171 to 187
private static final Set<String> JWK_PRIVATE_KEY_PARAMS = Set.of("d", "p", "q", "dp", "dq", "qi", "oth", "k");

public static String stripPrivateKeyParams(String jwks) {
try {
JSONWebKeySet keySet = JsonSerialization.readValue(jwks, JSONWebKeySet.class);
if (keySet != null && keySet.getKeys() != null) {
for (JWK key : keySet.getKeys()) {
key.getOtherClaims().keySet().removeAll(JWK_PRIVATE_KEY_PARAMS);
}
return JsonSerialization.writeValueAsPrettyString(keySet);
}
return jwks;
} catch (IOException e) {
logger.warn("Failed to parse JWKS for private key stripping, storing as-is", e);
return jwks;
}
}
Comment on lines +183 to +186
} catch (IOException e) {
logger.warn("Failed to parse JWKS for private key stripping, storing as-is", e);
return jwks;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stop storing client private keys in the database and deprecate generate endpoints

2 participants