VYPR
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.

PackageAffected versionsPatched versions
org.keycloak:keycloak-servicesMaven
< 26.5.726.5.7

Affected products

5
  • cpe: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

1
9046f201125a

Adding namespaces for single-use cache entries for PAR and OAuth code (#471)

https://github.com/keycloak/keycloakMarek PosoldaMar 23, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.