VYPR
High severity7.4NVD Advisory· Published Apr 2, 2026· Updated Apr 16, 2026

CVE-2026-4282

CVE-2026-4282

Description

A flaw was found in Keycloak. The SingleUseObjectProvider, a global key-value store, lacks proper type and namespace isolation. This vulnerability allows an unauthenticated attacker to forge authorization codes. Successful exploitation can lead to the creation of admin-capable access tokens, resulting in privilege escalation.

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.