Medium severity5.3NVD Advisory· Published Apr 2, 2026· Updated Apr 16, 2026
CVE-2026-4325
CVE-2026-4325
Description
A flaw was found in Keycloak. The SingleUseObjectProvider, a global key-value store, lacks proper type and namespace isolation. This vulnerability allows an attacker to delete arbitrary single-use entries, which can enable the replay of consumed action tokens, such as password reset links. This could lead to unauthorized access or account compromise.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.keycloak:keycloak-servicesMaven | < 26.5.7 | 26.5.7 |
Affected products
5cpe:2.3:a:redhat:build_of_keycloak:-:*:*:*:text-only:*:*:*+ 4 more
- cpe:2.3:a:redhat:build_of_keycloak:-:*:*:*:text-only:*:*:*
- cpe:2.3:a:redhat:build_of_keycloak:26.2:*:*:*:text-only:*:*:*
- cpe:2.3:a:redhat:build_of_keycloak:26.2.15:*:*:*:text-only:*:*:*
- cpe:2.3:a:redhat:build_of_keycloak:26.4:*:*:*:text-only:*:*:*
- cpe:2.3:a:redhat:build_of_keycloak:26.4.11:*:*:*:text-only:*:*:*
Patches
19046f201125aAdding namespaces for single-use cache entries for PAR and OAuth code (#471)
8 files changed · +103 −13
server-spi/src/main/java/org/keycloak/models/DefaultActionTokenKey.java+2 −1 modified@@ -21,6 +21,7 @@ import java.util.UUID; import java.util.regex.Pattern; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.representations.JsonWebToken; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -47,7 +48,7 @@ public DefaultActionTokenKey(String userId, String actionId, int absoluteExpirat this.subject = userId; this.type = actionId; this.exp = Long.valueOf(absoluteExpirationInSecs); - this.actionVerificationNonce = actionVerificationNonce == null ? UUID.randomUUID() : actionVerificationNonce; + this.actionVerificationNonce = actionVerificationNonce == null ? SecretGenerator.getInstance().generateSecureUUID() : actionVerificationNonce; } @JsonIgnore
services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java+5 −4 modified@@ -20,7 +20,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; @@ -33,6 +32,7 @@ import org.keycloak.OAuthErrorException; import org.keycloak.common.Profile; +import org.keycloak.common.util.SecretGenerator; import org.keycloak.events.Details; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; @@ -62,8 +62,9 @@ public class ParEndpoint extends AbstractParEndpoint { public static final String PAR_CREATED_TIME = "par.created.time"; public static final String PAR_DPOP_PROOF_JKT = "par.dpop.proof.jkt"; - private static final String REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; + public static final String REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; public static final int REQUEST_URI_PREFIX_LENGTH = REQUEST_URI_PREFIX.length(); + public static final String CACHE_KEY_PREFIX = "par:"; private final HttpRequest httpRequest; @@ -165,7 +166,7 @@ public Response request() { Map<String, String> params = new HashMap<>(); - String key = UUID.randomUUID().toString(); + String key = SecretGenerator.getInstance().generateSecureID(); String requestUri = REQUEST_URI_PREFIX + key; int expiresIn = realm.getParPolicy().getRequestUriLifespan(); @@ -180,7 +181,7 @@ public Response request() { } SingleUseObjectProvider singleUseStore = session.singleUseObjects(); - singleUseStore.put(key, expiresIn, params); + singleUseStore.put(CACHE_KEY_PREFIX + key, expiresIn, params); ParResponse parResponse = new ParResponse(requestUri, expiresIn);
services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java+2 −1 modified@@ -32,6 +32,7 @@ import org.jboss.logging.Logger; +import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.CACHE_KEY_PREFIX; import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.PAR_CREATED_TIME; import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.PAR_DPOP_PROOF_JKT; @@ -60,7 +61,7 @@ public AuthzEndpointParParser(KeycloakSession session, ClientModel client, Strin logger.warnf(re,"Unable to parse request_uri: %s", requestUri); throw new RuntimeException("Unable to parse request_uri"); } - Map<String, String> retrievedRequest = singleUseStore.remove(key); + Map<String, String> retrievedRequest = singleUseStore.remove(CACHE_KEY_PREFIX + key); if (retrievedRequest == null) { throw new RuntimeException("PAR not found. not issued or used multiple times."); }
services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java+3 −3 modified@@ -31,15 +31,15 @@ */ public class OAuth2Code { - private static final String ID_NOTE = "id"; - private static final String EXPIRATION_NOTE = "exp"; + public static final String ID_NOTE = "id"; + public static final String EXPIRATION_NOTE = "exp"; private static final String NONCE_NOTE = "nonce"; private static final String SCOPE_NOTE = "scope"; private static final String REDIRECT_URI_PARAM_NOTE = "redirectUri"; private static final String CODE_CHALLENGE_NOTE = "code_challenge"; private static final String CODE_CHALLENGE_METHOD_NOTE = "code_challenge_method"; private static final String DPOP_JKT_NOTE = "dpop_jkt"; - private static final String USER_SESSION_ID_NOTE = "user_session_id"; + public static final String USER_SESSION_ID_NOTE = "user_session_id"; private final String id;
services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java+3 −2 modified@@ -39,6 +39,7 @@ public class OAuth2CodeParser { private static final Logger logger = Logger.getLogger(OAuth2CodeParser.class); private static final Pattern DOT = Pattern.compile("\\."); + private static final String CACHE_KEY_PREFIX = "code:"; /** * Will persist the code to the cache and return the object with the codeData and code correctly set @@ -54,7 +55,7 @@ public static String persistCode(KeycloakSession session, AuthenticatedClientSes } Map<String, String> serialized = codeData.serializeCode(); - codeStore.put(key, clientSession.getUserSession().getRealm().getAccessCodeLifespan(), serialized); + codeStore.put(CACHE_KEY_PREFIX + key, clientSession.getUserSession().getRealm().getAccessCodeLifespan(), serialized); return key + "." + clientSession.getUserSession().getId() + "." + clientSession.getClient().getId(); } @@ -94,7 +95,7 @@ public static ParseResult parseCode(KeycloakSession session, String code, RealmM result.clientSession = userSession.getAuthenticatedClientSessionByClient(clientUUID); SingleUseObjectProvider codeStore = session.singleUseObjects(); - Map<String, String> codeData = codeStore.remove(codeUUID); + Map<String, String> codeData = codeStore.remove(CACHE_KEY_PREFIX + codeUUID); // Either code not available or was already used if (codeData == null) {
services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureParContentsExecutor.java+2 −2 modified@@ -85,7 +85,7 @@ private void checkValidParContents(PreAuthorizationRequestContext preAuthorizati String key = requestUri.substring(ParEndpoint.REQUEST_URI_PREFIX_LENGTH); SingleUseObjectProvider singleUseStore = session.singleUseObjects(); - Map<String, String> requestParametersFromPAR = singleUseStore.get(key); + Map<String, String> requestParametersFromPAR = singleUseStore.get(ParEndpoint.CACHE_KEY_PREFIX + key); if (requestParametersFromPAR == null) { throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "PAR not found. not issued or used multiple times."); } @@ -100,7 +100,7 @@ private void checkValidParContents(PreAuthorizationRequestContext preAuthorizati for (String queryParamName : requestParametersFromQuery.keySet()) { if (!requestParametersNameFromPAR.contains(queryParamName) && !OIDCLoginProtocol.REQUEST_URI_PARAM.equals(queryParamName)) { - singleUseStore.remove(key); + singleUseStore.remove(ParEndpoint.CACHE_KEY_PREFIX + key); throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "PAR request did not include necessary parameters"); } }
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java+35 −0 modified@@ -26,18 +26,22 @@ import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken; import org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail; import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.UriUtils; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.Constants; +import org.keycloak.models.DefaultActionTokenKey; +import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.SystemClientUtil; @@ -98,6 +102,8 @@ import org.openqa.selenium.firefox.FirefoxDriver; import org.openqa.selenium.htmlunit.HtmlUnitDriver; +import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.REQUEST_URI_PREFIX; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.endsWith; import static org.junit.Assert.assertEquals; @@ -352,6 +358,35 @@ public void resetPasswordTwice() throws IOException, MessagingException { assertSecondPasswordResetFails(changePasswordUrl, oauth.getClientId()); // KC_RESTART doesn't exist, it was deleted after first successful reset-password flow was finished } + @Test + public void resetPasswordTwiceWithPARRequestInBetween() throws Exception { + String changePasswordUrl = resetPassword("login-test"); + events.clear(); + + // Try to create manually the cache key from actionToken + String queryString = changePasswordUrl.substring(changePasswordUrl.indexOf("?") + 1); + MultivaluedHashMap<String, String> params = UriUtils.decodeQueryString(queryString); + String key = params.getFirst(Constants.KEY); + TokenVerifier<DefaultActionTokenKey> tokenVerifier = TokenVerifier.create(key, DefaultActionTokenKey.class); + DefaultActionTokenKey aToken = tokenVerifier.getToken(); + String serializedKey1 = aToken.serializeKey(); + String serializedKey2 = serializedKey1 + SingleUseObjectProvider.REVOKED_KEY; + + // Try to send PAR request with manually created requestUri related to the manually created serialized keys + String state = "testSuccessfulSinglePar"; + oauth.loginForm() + .requestUri(REQUEST_URI_PREFIX + serializedKey1) + .state(state) + .open(); + oauth.loginForm() + .requestUri(REQUEST_URI_PREFIX + serializedKey2) + .state(state) + .open(); + events.clear(); + + assertSecondPasswordResetFails(changePasswordUrl, oauth.getClientId()); // KC_RESTART doesn't exist, it was deleted after first successful reset-password flow was finished + } + @Test public void resetPasswordForceLogin() throws IOException, MessagingException { // add the force login option in the reset-credential-email authenticator
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/par/ParTest.java+51 −0 modified@@ -38,6 +38,7 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OAuth2Code; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.RefreshToken; @@ -54,6 +55,7 @@ import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource; import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutorFactory; +import org.keycloak.testsuite.util.AccountHelper; import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder; import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder; @@ -64,12 +66,14 @@ import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.oauth.OAuthClient; +import org.keycloak.testsuite.util.oauth.ParRequest; import org.keycloak.testsuite.util.oauth.ParResponse; import org.keycloak.util.JsonSerialization; import org.junit.Assert; import org.junit.Test; +import static org.keycloak.OAuthErrorException.INVALID_GRANT; import static org.keycloak.testsuite.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientRolesConditionConfig; @@ -80,6 +84,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -294,6 +299,52 @@ public void testSuccessfulSingleParPublicClient() throws Exception { } } + + // Test manually created PAR request cannot be used to obtain OAuth2 code + @Test + public void testParDoNotClashWithAuthorizationCode() throws Exception { + try { + // setup PAR realm settings + int requestUriLifespan = 45; + setParRealmSettings(requestUriLifespan); + + // Step 1 - Login as regular user and obtain sessionId + oauth.doLogin(TEST_USER2_NAME, TEST_USER2_PASSWORD); + AuthorizationEndpointResponse authzResponse = oauth.parseLoginResponse(); + String sessionId = authzResponse.getSessionState(); + String code = authzResponse.getCode(); + assertNotNull(sessionId); + assertNotNull(code); + + // Step 2: PAR request with some custom injected parameters + ParRequest pReq = new ParRequest(oauth) { + + @Override + protected void initRequest() { + super.initRequest(); + parameter(OAuth2Code.ID_NOTE, "some-id"); + parameter(OAuth2Code.USER_SESSION_ID_NOTE, sessionId); + parameter(OAuth2Code.EXPIRATION_NOTE, String.valueOf(Time.currentTime() + 9999)); + } + }; + ParResponse pResp = pReq.send(); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + + // Step 3: Attempt to exchange code for token with the "fake code" from PAR + String parId = requestUri.substring(requestUri.lastIndexOf(":" ) + 1); + String clientUUID = ApiUtil.findClientByClientId(adminClient.realm("test"), oauth.getClientId()).toRepresentation().getId(); + String fakeCode = parId + "." + sessionId + "." + clientUUID; + AccessTokenResponse response = oauth.doAccessTokenRequest(fakeCode); + assertEquals(400, response.getStatusCode()); + assertEquals(INVALID_GRANT, response.getError()); + } finally { + // Logout + AccountHelper.logout(adminClient.realm(oauth.getRealm()), TEST_USER2_NAME); + restoreParRealmSettings(); + } + } + @Test public void testWrongSigningAlgorithmForRequestObject() throws Exception { try {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- access.redhat.com/errata/RHSA-2026:6475nvdVendor AdvisoryWEB
- access.redhat.com/errata/RHSA-2026:6476nvdVendor AdvisoryWEB
- access.redhat.com/errata/RHSA-2026:6477nvdVendor AdvisoryWEB
- access.redhat.com/errata/RHSA-2026:6478nvdVendor AdvisoryWEB
- access.redhat.com/security/cve/CVE-2026-4325nvdVendor AdvisoryWEB
- bugzilla.redhat.com/show_bug.cginvdIssue TrackingVendor AdvisoryWEB
- github.com/advisories/GHSA-rx66-hj7g-28h7ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-4325ghsaADVISORY
- github.com/keycloak/keycloak/commit/9046f201125a6fd6be9c116b99d348509d99d4a5ghsaWEB
- github.com/keycloak/keycloak/issues/47715ghsaWEB
News mentions
0No linked articles in our index yet.