Mobile applications and Single Page Applications (SPAs) face unique security challenges in the OAuth 2.0 world. Unlike confidential clients, these public clients cannot securely store client secrets, making them vulnerable to authorization code interception attacks. PKCE (Proof Key for Code Exchange) , pronounced "pixie," solves this vulnerability by ensuring that even if an authorization code is intercepted, it cannot be exchanged for tokens without the original code verifier. For Java developers building mobile backends or SPA APIs, implementing PKCE correctly is essential for modern OAuth security.
What is PKCE?
PKCE is an extension to the OAuth 2.0 authorization code flow that prevents authorization code interception attacks. It works by:
- Code Verifier: The client generates a cryptographically random secret
- Code Challenge: The client creates a transformed version of the verifier (usually SHA256 hashed)
- Authorization Request: The client sends the challenge (not the verifier) with the authorization request
- Token Request: When exchanging the code for tokens, the client sends the original verifier
- Verification: The authorization server verifies that the verifier matches the challenge
Mobile App (Public Client) Authorization Server | | |-- Generate code_verifier ------> | | | |-- /authorize? | | code_challenge=S256(verifier) ->| | | |<-- Authorization Code ----------- | | | |-- /token? | | code_verifier=original -------->| | | |<-- Access Token ------------------| | |
Why PKCE is Critical for Public Clients
- No Client Secret Required: Public clients cannot securely store secrets
- Authorization Code Interception Protection: Even if the code is intercepted, it's useless without the verifier
- Mandatory for OAuth 2.1: PKCE is required in the upcoming OAuth 2.1 specification
- Native App Security: Protects against malicious apps on the same device
- SPA Security: Prevents authorization code leakage from browser history
PKCE Java Implementation for Authorization Server
1. PKCE Dependencies
<dependencies> <!-- Spring Security OAuth2 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> <version>3.1.5</version> </dependency> <!-- For PKCE utilities --> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.16.0</version> </dependency> <!-- For secure random generation --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.13.0</version> </dependency> </dependencies>
2. PKCE Configuration for Authorization Server
@Configuration @EnableAuthorizationServer public class PkceAuthorizationServerConfig { @Bean public AuthorizationServerSecurityConfigurer authorizationServerSecurityConfigurer() { return new AuthorizationServerSecurityConfigurer() { @Override public void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/oauth/token").fullyAuthenticated() .and() .requestMatchers() .antMatchers("/oauth/authorize", "/oauth/token", "/oauth/check_token"); } }; } @Bean public AuthorizationEndpointConfig authorizationEndpointConfig() { return new AuthorizationEndpointConfig(); } @Bean public PkceAuthorizationCodeServices pkceAuthorizationCodeServices() { return new PkceAuthorizationCodeServices(); } @Bean public PkceTokenGranter pkceTokenGranter( AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { return new PkceTokenGranter( tokenServices, clientDetailsService, requestFactory ); } } 3. PKCE-Aware Authorization Code Services
@Component public class PkceAuthorizationCodeServices extends RandomValueAuthorizationCodeServices { private static final Logger logger = LoggerFactory.getLogger(PkceAuthorizationCodeServices.class); // Store authorization code with PKCE data private final Map<String, PkceAuthorizationCode> codeStore = new ConcurrentHashMap<>(); @Override protected void store(String code, OAuth2Authentication authentication) { // Extract PKCE parameters from the authentication request OAuth2Request request = authentication.getOAuth2Request(); Map<String, String> requestParams = request.getRequestParameters(); String codeChallenge = requestParams.get("code_challenge"); String codeChallengeMethod = requestParams.get("code_challenge_method"); // Validate PKCE parameters if (codeChallenge == null) { // For public clients, PKCE should be required if (isPublicClient(request.getClientId())) { throw new PkceRequiredException("PKCE is required for public clients"); } // For confidential clients, PKCE is optional but recommended } // Store the code with its PKCE data PkceAuthorizationCode pkceCode = PkceAuthorizationCode.builder() .code(code) .authentication(authentication) .codeChallenge(codeChallenge) .codeChallengeMethod(codeChallengeMethod) .createdAt(Instant.now()) .build(); codeStore.put(code, pkceCode); logger.debug("Stored authorization code with PKCE: {}", code); } @Override protected OAuth2Authentication remove(String code) { PkceAuthorizationCode pkceCode = codeStore.remove(code); if (pkceCode != null) { // Store in a temporary cache for token request validation // The verifier will be validated during token exchange PkceValidationCache.put(code, pkceCode); return pkceCode.getAuthentication(); } return null; } /** * Validate PKCE during token exchange */ public void validatePkce(String code, String codeVerifier) { PkceAuthorizationCode pkceCode = PkceValidationCache.get(code); if (pkceCode == null) { throw new InvalidGrantException("Invalid authorization code"); } // Check if PKCE was used in the authorization request if (pkceCode.getCodeChallenge() == null) { // No PKCE in auth request, skip validation return; } // Validate code verifier is present if (codeVerifier == null) { throw new PkceValidationException("code_verifier required for PKCE flow"); } // Validate code verifier length (RFC 7636) if (codeVerifier.length() < 43 || codeVerifier.length() > 128) { throw new PkceValidationException("code_verifier must be between 43 and 128 characters"); } // Validate code verifier characters (only alphanumeric and punctuation) if (!codeVerifier.matches("^[A-Za-z0-9\\-\\.~_]+$")) { throw new PkceValidationException("code_verifier contains invalid characters"); } // Compute challenge from verifier String computedChallenge; if ("S256".equals(pkceCode.getCodeChallengeMethod())) { computedChallenge = generateS256Challenge(codeVerifier); } else if ("plain".equals(pkceCode.getCodeChallengeMethod())) { computedChallenge = codeVerifier; } else { throw new PkceValidationException("Unsupported code_challenge_method: " + pkceCode.getCodeChallengeMethod()); } // Compare with original challenge if (!MessageDigest.isEqual( computedChallenge.getBytes(StandardCharsets.UTF_8), pkceCode.getCodeChallenge().getBytes(StandardCharsets.UTF_8))) { logger.warn("PKCE validation failed for code: {}", code); throw new PkceValidationException("code_verifier does not match code_challenge"); } logger.debug("PKCE validation successful for code: {}", code); // Remove from cache after validation PkceValidationCache.remove(code); } private String generateS256Challenge(String codeVerifier) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 not available", e); } } private boolean isPublicClient(String clientId) { // Check client configuration - public clients have no secret // This could be loaded from client details service return true; // Simplified for example } @Data @Builder private static class PkceAuthorizationCode { private String code; private OAuth2Authentication authentication; private String codeChallenge; private String codeChallengeMethod; private Instant createdAt; } // Simple cache for codes awaiting PKCE validation private static class PkceValidationCache { private static final Map<String, PkceAuthorizationCode> cache = new ConcurrentHashMap<>(); private static final Duration TTL = Duration.ofMinutes(5); public static void put(String code, PkceAuthorizationCode pkceCode) { cache.put(code, pkceCode); // Schedule cleanup ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); executor.schedule(() -> cache.remove(code), TTL.toMillis(), TimeUnit.MILLISECONDS); } public static PkceAuthorizationCode get(String code) { return cache.get(code); } public static void remove(String code) { cache.remove(code); } } } 4. PKCE Token Granter
public class PkceTokenGranter extends AbstractTokenGranter { private static final String GRANT_TYPE = "authorization_code"; private final PkceAuthorizationCodeServices pkceCodeServices; public PkceTokenGranter( AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE); this.pkceCodeServices = (PkceAuthorizationCodeServices) ((DefaultTokenServices) tokenServices).getAuthorizationCodeServices(); } @Override protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) { Map<String, String> parameters = tokenRequest.getRequestParameters(); String authorizationCode = parameters.get("code"); String codeVerifier = parameters.get("code_verifier"); // Validate PKCE pkceCodeServices.validatePkce(authorizationCode, codeVerifier); // Proceed with normal authorization code grant return super.getOAuth2Authentication(client, tokenRequest); } } 5. PKCE-Aware Client Details
@Component public class PkceClientDetailsService implements ClientDetailsService { private final Map<String, PkceClientDetails> clients = new ConcurrentHashMap<>(); @PostConstruct public void init() { // Example public client (mobile app) clients.put("mobile-app", PkceClientDetails.builder() .clientId("mobile-app") .clientSecret(null) // No secret for public client .scope(Set.of("openid", "profile", "email")) .authorizedGrantTypes(Set.of("authorization_code", "refresh_token")) .redirectUris(Set.of( "com.example.app://callback", "https://example.com/callback" )) .accessTokenValiditySeconds(3600) .refreshTokenValiditySeconds(86400) .requirePkce(true) // Force PKCE for this client .build()); // Example SPA client clients.put("spa-client", PkceClientDetails.builder() .clientId("spa-client") .clientSecret(null) .scope(Set.of("openid", "profile")) .authorizedGrantTypes(Set.of("authorization_code")) .redirectUris(Set.of( "https://localhost:3000/callback", "https://myapp.com/callback" )) .requirePkce(true) .build()); } @Override public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException { PkceClientDetails client = clients.get(clientId); if (client == null) { throw new NoSuchClientException("Client not found: " + clientId); } return client; } @Data @Builder public static class PkceClientDetails implements ClientDetails { private String clientId; private String clientSecret; private Set<String> scope; private Set<String> authorizedGrantTypes; private Set<String> redirectUris; private Integer accessTokenValiditySeconds; private Integer refreshTokenValiditySeconds; private boolean requirePkce; @Override public Set<String> getResourceIds() { return Set.of(); } @Override public Set<String> getAuthorities() { return Set.of(); } @Override public boolean isSecretRequired() { return clientSecret != null; } @Override public boolean isScoped() { return scope != null && !scope.isEmpty(); } @Override public Set<String> getRegisteredRedirectUri() { return redirectUris; } @Override public boolean isAutoApprove(String scope) { return false; } @Override public Map<String, Object> getAdditionalInformation() { Map<String, Object> info = new HashMap<>(); info.put("require_pkce", requirePkce); return info; } } } PKCE Client Implementation in Java
6. PKCE Client for Mobile/Desktop Apps
public class PkceOAuth2Client { private static final Logger logger = LoggerFactory.getLogger(PkceOAuth2Client.class); private static final SecureRandom secureRandom = new SecureRandom(); private final String clientId; private final String redirectUri; private final String authorizationEndpoint; private final String tokenEndpoint; // Store PKCE data for in-progress flows private final Map<String, PkceSession> sessions = new ConcurrentHashMap<>(); public PkceOAuth2Client(String clientId, String redirectUri, String authorizationEndpoint, String tokenEndpoint) { this.clientId = clientId; this.redirectUri = redirectUri; this.authorizationEndpoint = authorizationEndpoint; this.tokenEndpoint = tokenEndpoint; } /** * Generate PKCE challenge and create authorization URL */ public AuthorizationRequest createAuthorizationRequest(String state, Set<String> scopes) { // Generate code verifier String codeVerifier = generateCodeVerifier(); String codeChallenge = generateCodeChallenge(codeVerifier); String codeChallengeMethod = "S256"; // Generate PKCE session ID String sessionId = UUID.randomUUID().toString(); // Store session data sessions.put(sessionId, new PkceSession(codeVerifier, state)); // Build authorization URL String authUrl = UriComponentsBuilder.fromHttpUrl(authorizationEndpoint) .queryParam("response_type", "code") .queryParam("client_id", clientId) .queryParam("redirect_uri", redirectUri) .queryParam("scope", String.join(" ", scopes)) .queryParam("state", state) .queryParam("code_challenge", codeChallenge) .queryParam("code_challenge_method", codeChallengeMethod) .queryParam("session_id", sessionId) // Custom param to track session .build() .toUriString(); logger.debug("Created authorization URL with PKCE"); return new AuthorizationRequest(authUrl, sessionId, state); } /** * Exchange authorization code for tokens */ public TokenResponse exchangeCodeForTokens(String authorizationCode, String sessionId) { // Retrieve PKCE session PkceSession session = sessions.get(sessionId); if (session == null) { throw new PkceException("No PKCE session found"); } String codeVerifier = session.getCodeVerifier(); // Build token request HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> body = new LinkedMultiValueMap<>(); body.add("grant_type", "authorization_code"); body.add("code", authorizationCode); body.add("redirect_uri", redirectUri); body.add("client_id", clientId); body.add("code_verifier", codeVerifier); // Create and execute request RestTemplate restTemplate = new RestTemplate(); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers); try { ResponseEntity<Map> response = restTemplate.postForEntity( tokenEndpoint, request, Map.class ); // Verify state matches Map<String, Object> responseBody = response.getBody(); String returnedState = (String) responseBody.get("state"); if (!session.getState().equals(returnedState)) { throw new PkceException("State mismatch - possible CSRF attack"); } // Clean up session sessions.remove(sessionId); logger.info("Successfully exchanged code for tokens with PKCE"); return new TokenResponse(responseBody); } catch (HttpClientErrorException e) { logger.error("Token exchange failed: {}", e.getResponseBodyAsString()); throw new PkceException("Token exchange failed: " + e.getMessage(), e); } } /** * Generate cryptographically random code verifier * RFC 7636 requires minimum 43 chars, maximum 128 chars */ private String generateCodeVerifier() { byte[] randomBytes = new byte[32]; // 256 bits secureRandom.nextBytes(randomBytes); // Base64URL encode without padding String verifier = Base64.getUrlEncoder().withoutPadding() .encodeToString(randomBytes); // Ensure length requirements if (verifier.length() < 43) { // Pad if necessary (shouldn't happen with 32 bytes) verifier = StringUtils.rightPad(verifier, 43, 'A'); } return verifier; } /** * Generate S256 code challenge from verifier */ private String generateCodeChallenge(String codeVerifier) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 not available", e); } } @Data @AllArgsConstructor private static class PkceSession { private String codeVerifier; private String state; } @Data public static class AuthorizationRequest { private final String url; private final String sessionId; private final String state; } @Data public static class TokenResponse { private final String accessToken; private final String refreshToken; private final String idToken; private final String tokenType; private final long expiresIn; private final Map<String, Object> additionalParams; public TokenResponse(Map<String, Object> response) { this.accessToken = (String) response.get("access_token"); this.refreshToken = (String) response.get("refresh_token"); this.idToken = (String) response.get("id_token"); this.tokenType = (String) response.get("token_type"); this.expiresIn = response.containsKey("expires_in") ? ((Number) response.get("expires_in")).longValue() : 0; this.additionalParams = new HashMap<>(response); } } } 7. Android Mobile App PKCE Implementation
// Android Activity for OAuth2 with PKCE public class LoginActivity extends AppCompatActivity { private static final String TAG = "LoginActivity"; private static final int REQUEST_CODE_AUTHORIZE = 1001; private PkceOAuth2Client oauth2Client; private String currentSessionId; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); // Initialize PKCE client oauth2Client = new PkceOAuth2Client( "mobile-app", "com.example.app://callback", "https://auth.example.com/oauth/authorize", "https://auth.example.com/oauth/token" ); Button loginButton = findViewById(R.id.btn_login); loginButton.setOnClickListener(v -> startAuthorization()); // Handle deep link callback handleIntent(getIntent()); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); handleIntent(intent); } private void startAuthorization() { // Generate state for CSRF protection String state = generateSecureState(); // Create PKCE authorization request PkceOAuth2Client.AuthorizationRequest request = oauth2Client.createAuthorizationRequest(state, Set.of("openid", "profile", "email")); currentSessionId = request.getSessionId(); // Store state for verification saveState(state); // Launch browser for authorization CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); CustomTabsIntent customTabsIntent = builder.build(); customTabsIntent.launchUrl(this, Uri.parse(request.getUrl())); } private void handleIntent(Intent intent) { if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction())) { Uri data = intent.getData(); if (data != null && data.toString().startsWith("com.example.app://callback")) { String authorizationCode = data.getQueryParameter("code"); String state = data.getQueryParameter("state"); String sessionId = data.getQueryParameter("session_id"); // Verify state if (!verifyState(state)) { showError("Security verification failed"); return; } // Exchange code for tokens with PKCE exchangeCodeForTokens(authorizationCode, sessionId); } } } private void exchangeCodeForTokens(String code, String sessionId) { // Show progress ProgressDialog progress = ProgressDialog.show(this, "Logging in", "Please wait..."); // Perform token exchange in background ExecutorService executor = Executors.newSingleThreadExecutor(); Handler handler = new Handler(Looper.getMainLooper()); executor.execute(() -> { try { PkceOAuth2Client.TokenResponse response = oauth2Client.exchangeCodeForTokens(code, sessionId); handler.post(() -> { progress.dismiss(); onLoginSuccess(response); }); } catch (Exception e) { handler.post(() -> { progress.dismiss(); showError("Login failed: " + e.getMessage()); }); } }); } private String generateSecureState() { byte[] randomBytes = new byte[32]; new SecureRandom().nextBytes(randomBytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); } private void saveState(String state) { SharedPreferences prefs = getSharedPreferences("auth", MODE_PRIVATE); prefs.edit().putString("oauth_state", state).apply(); } private boolean verifyState(String returnedState) { SharedPreferences prefs = getSharedPreferences("auth", MODE_PRIVATE); String savedState = prefs.getString("oauth_state", null); return savedState != null && savedState.equals(returnedState); } private void onLoginSuccess(PkceOAuth2Client.TokenResponse response) { // Store tokens securely SecureTokenStore.getInstance().saveTokens(response); // Navigate to main activity Intent intent = new Intent(this, MainActivity.class); startActivity(intent); finish(); } } 8. SPA PKCE Implementation (for completeness)
// JavaScript PKCE client for SPA class PkceOAuth2Client { constructor(config) { this.clientId = config.clientId; this.redirectUri = config.redirectUri; this.authorizationEndpoint = config.authorizationEndpoint; this.tokenEndpoint = config.tokenEndpoint; } // Generate random string for code verifier generateCodeVerifier() { const array = new Uint8Array(32); crypto.getRandomValues(array); return btoa(String.fromCharCode.apply(null, array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') .substring(0, 128); } // Generate S256 code challenge async generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const digest = await crypto.subtle.digest('SHA-256', data); return btoa(String.fromCharCode.apply(null, new Uint8Array(digest))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } // Create authorization URL async createAuthorizationUrl() { const state = this.generateCodeVerifier(); // Use same method for state const codeVerifier = this.generateCodeVerifier(); const codeChallenge = await this.generateCodeChallenge(codeVerifier); // Store in session storage sessionStorage.setItem('pkce_verifier', codeVerifier); sessionStorage.setItem('oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: this.clientId, redirect_uri: this.redirectUri, scope: 'openid profile email', state: state, code_challenge: codeChallenge, code_challenge_method: 'S256' }); return `${this.authorizationEndpoint}?${params.toString()}`; } // Exchange code for tokens async exchangeCode(code, returnedState) { // Verify state const savedState = sessionStorage.getItem('oauth_state'); if (returnedState !== savedState) { throw new Error('State mismatch - possible CSRF attack'); } // Get code verifier const codeVerifier = sessionStorage.getItem('pkce_verifier'); // Clear session storage sessionStorage.removeItem('pkce_verifier'); sessionStorage.removeItem('oauth_state'); // Exchange code const response = await fetch(this.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: this.redirectUri, client_id: this.clientId, code_verifier: codeVerifier }) }); if (!response.ok) { throw new Error('Token exchange failed'); } return await response.json(); } } 9. PKCE Validation in Resource Server
@Component public class PkceAwareTokenValidator { /** * Validate token including PKCE binding information */ public boolean validateTokenWithPkceBinding(String token, String clientCertificate) { try { SignedJWT signedJWT = SignedJWT.parse(token); JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); // Check if token was issued with PKCE String codeChallenge = claims.getStringClaim("code_challenge"); String codeChallengeMethod = claims.getStringClaim("code_challenge_method"); if (codeChallenge != null) { // Token was issued with PKCE - this is good for public clients logger.debug("Token issued with PKCE: method={}", codeChallengeMethod); // Optionally validate that the client is still using the same binding // For mobile apps, this could be certificate binding if (clientCertificate != null) { validateCertificateBinding(claims, clientCertificate); } } return true; } catch (ParseException e) { logger.error("Failed to parse token", e); return false; } } private void validateCertificateBinding(JWTClaimsSet claims, String clientCertificate) { Map<String, Object> cnf = (Map<String, Object>) claims.getClaim("cnf"); if (cnf != null && cnf.containsKey("x5t#S256")) { String expectedThumbprint = (String) cnf.get("x5t#S256"); String actualThumbprint = calculateThumbprint(clientCertificate); if (!expectedThumbprint.equals(actualThumbprint)) { throw new PkceBindingException("Certificate binding mismatch"); } } } } 10. PKCE Testing
@SpringBootTest @AutoConfigureMockMvc public class PkceIntegrationTest { @Autowired private MockMvc mockMvc; @Autowired private PkceAuthorizationCodeServices pkceCodeServices; @Test public void testPkceFlow() throws Exception { // 1. Generate PKCE parameters String codeVerifier = generateCodeVerifier(); String codeChallenge = generateS256Challenge(codeVerifier); String state = UUID.randomUUID().toString(); // 2. Authorization request with PKCE MvcResult authResult = mockMvc.perform(get("/oauth/authorize") .param("response_type", "code") .param("client_id", "mobile-app") .param("redirect_uri", "com.example.app://callback") .param("state", state) .param("code_challenge", codeChallenge) .param("code_challenge_method", "S256") .with(user("testuser").roles("USER"))) .andExpect(status().is3xxRedirection()) .andReturn(); // Extract authorization code String location = authResult.getResponse().getHeader("Location"); String code = extractCode(location); // 3. Token request with code verifier mockMvc.perform(post("/oauth/token") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("grant_type", "authorization_code") .param("code", code) .param("redirect_uri", "com.example.app://callback") .param("client_id", "mobile-app") .param("code_verifier", codeVerifier)) .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").exists()) .andExpect(jsonPath("$.token_type").value("bearer")); } @Test public void testPkceRequiredForPublicClient() throws Exception { String codeVerifier = generateCodeVerifier(); String codeChallenge = generateS256Challenge(codeVerifier); String state = UUID.randomUUID().toString(); // Authorization request without PKCE (should fail for public client) MvcResult authResult = mockMvc.perform(get("/oauth/authorize") .param("response_type", "code") .param("client_id", "mobile-app") // Public client .param("redirect_uri", "com.example.app://callback") .param("state", state) .with(user("testuser").roles("USER"))) .andExpect(status().is3xxRedirection()) .andReturn(); String location = authResult.getResponse().getHeader("Location"); assertTrue(location.contains("error=invalid_request")); assertTrue(location.contains("error_description=PKCE+required")); } @Test public void testInvalidCodeVerifier() throws Exception { // 1. Authorization with PKCE String codeVerifier = generateCodeVerifier(); String codeChallenge = generateS256Challenge(codeVerifier); String state = UUID.randomUUID().toString(); MvcResult authResult = mockMvc.perform(get("/oauth/authorize") .param("response_type", "code") .param("client_id", "mobile-app") .param("redirect_uri", "com.example.app://callback") .param("state", state) .param("code_challenge", codeChallenge) .param("code_challenge_method", "S256") .with(user("testuser").roles("USER"))) .andExpect(status().is3xxRedirection()) .andReturn(); String location = authResult.getResponse().getHeader("Location"); String code = extractCode(location); // 2. Token request with WRONG code verifier String wrongVerifier = generateCodeVerifier(); // Different verifier mockMvc.perform(post("/oauth/token") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("grant_type", "authorization_code") .param("code", code) .param("redirect_uri", "com.example.app://callback") .param("client_id", "mobile-app") .param("code_verifier", wrongVerifier)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("invalid_grant")); } @Test public void testMissingCodeVerifier() throws Exception { // 1. Authorization with PKCE String codeVerifier = generateCodeVerifier(); String codeChallenge = generateS256Challenge(codeVerifier); String state = UUID.randomUUID().toString(); MvcResult authResult = mockMvc.perform(get("/oauth/authorize") .param("response_type", "code") .param("client_id", "mobile-app") .param("redirect_uri", "com.example.app://callback") .param("state", state) .param("code_challenge", codeChallenge) .param("code_challenge_method", "S256") .with(user("testuser").roles("USER"))) .andExpect(status().is3xxRedirection()) .andReturn(); String location = authResult.getResponse().getHeader("Location"); String code = extractCode(location); // 2. Token request without code_verifier mockMvc.perform(post("/oauth/token") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("grant_type", "authorization_code") .param("code", code) .param("redirect_uri", "com.example.app://callback") .param("client_id", "mobile-app")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("invalid_request")); } @Test public void testPlainMethod() throws Exception { // Test with "plain" code challenge method (less secure, but supported) String codeVerifier = generateCodeVerifier(); String codeChallenge = codeVerifier; // Plain method uses verifier as challenge String state = UUID.randomUUID().toString(); MvcResult authResult = mockMvc.perform(get("/oauth/authorize") .param("response_type", "code") .param("client_id", "mobile-app") .param("redirect_uri", "com.example.app://callback") .param("state", state) .param("code_challenge", codeChallenge) .param("code_challenge_method", "plain") .with(user("testuser").roles("USER"))) .andExpect(status().is3xxRedirection()) .andReturn(); String location = authResult.getResponse().getHeader("Location"); String code = extractCode(location); // Token request with code verifier mockMvc.perform(post("/oauth/token") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("grant_type", "authorization_code") .param("code", code) .param("redirect_uri", "com.example.app://callback") .param("client_id", "mobile-app") .param("code_verifier", codeVerifier)) .andExpect(status().isOk()) .andExpect(jsonPath("$.access_token").exists()); } private String generateCodeVerifier() { byte[] randomBytes = new byte[32]; new SecureRandom().nextBytes(randomBytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); } private String generateS256Challenge(String verifier) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.US_ASCII)); return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } private String extractCode(String location) { String codeParam = "code="; int start = location.indexOf(codeParam) + codeParam.length(); int end = location.indexOf('&', start); if (end == -1) { end = location.length(); } return location.substring(start, end); } } Best Practices for PKCE Implementation
1. Code Verifier Generation
public class CodeVerifierGenerator { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); public static String generateSecureVerifier() { byte[] randomBytes = new byte[32]; // 256 bits of entropy SECURE_RANDOM.nextBytes(randomBytes); String verifier = Base64.getUrlEncoder().withoutPadding() .encodeToString(randomBytes); // RFC 7636: 43-128 characters if (verifier.length() < 43) { // This shouldn't happen with 32 bytes (43 chars) verifier = StringUtils.rightPad(verifier, 43, 'A'); } return verifier; } public static boolean isValidVerifier(String verifier) { return verifier != null && verifier.length() >= 43 && verifier.length() <= 128 && verifier.matches("^[A-Za-z0-9\\-\\.~_]+$"); } } 2. Challenge Method Selection
public enum PkceMethod { S256("S256", true), // Recommended PLAIN("plain", false); // Only for legacy compatibility private final String value; private final boolean secure; PkceMethod(String value, boolean secure) { this.value = value; this.secure = secure; } public String getValue() { return value; } public boolean isSecure() { return secure; } public static PkceMethod fromString(String method) { if (method == null) return S256; // Default to secure for (PkceMethod m : values()) { if (m.value.equalsIgnoreCase(method)) { return m; } } return S256; // Default to secure } } 3. Replay Attack Prevention
@Component public class PkceReplayPrevention { private final Cache<String, Boolean> usedNonces = Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(5)) .maximumSize(10000) .build(); public boolean isCodeVerifierReused(String codeVerifier) { // Hash the verifier to avoid storing the actual secret String hash = hashVerifier(codeVerifier); Boolean used = usedNonces.getIfPresent(hash); if (used != null) { return true; // Reused! } usedNonces.put(hash, true); return false; } private String hashVerifier(String verifier) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } } 4. Token Binding Enhancement
@Component public class PkceTokenBinding { /** * Enhance token with PKCE binding information */ public Map<String, Object> addPkceBinding( Map<String, Object> claims, String codeChallenge, String codeChallengeMethod) { Map<String, Object> enhanced = new HashMap<>(claims); // Add PKCE claims to token enhanced.put("code_challenge", codeChallenge); enhanced.put("code_challenge_method", codeChallengeMethod); // Add token binding for public clients if (isPublicClient(claims.get("client_id").toString())) { Map<String, Object> cnf = new HashMap<>(); cnf.put("jkt", generateKeyThumbprint(claims)); enhanced.put("cnf", cnf); } return enhanced; } } PKCE Security Considerations
| Attack Vector | PKCE Mitigation | Implementation |
|---|---|---|
| Authorization code interception | Code verifier required | ✅ PkceAuthorizationCodeServices |
| CSRF on redirect URI | State parameter | ✅ PkceOAuth2Client with state |
| Code verifier brute force | High entropy (256 bits) | ✅ SecureRandom 32 bytes |
| Replay attacks | One-time use verifier | ✅ PkceReplayPrevention |
| Downgrade attack | Enforce S256 method | ✅ Default to S256 |
Conclusion
PKCE is an essential security enhancement for public clients in the OAuth 2.0 ecosystem. By implementing PKCE in Java applications, developers can protect mobile apps and SPAs from authorization code interception attacks without requiring a client secret.
For authorization servers, PKCE validation ensures that only the client that initiated the authorization request can exchange the code for tokens. For clients, proper PKCE implementation with cryptographically secure verifiers and state parameters provides defense-in-depth against common OAuth vulnerabilities.
As OAuth 2.1 makes PKCE mandatory for all clients, Java applications that implement PKCE today will be ahead of the curve in providing secure, standards-compliant authentication for their users. Whether you're building an OAuth authorization server, a mobile app backend, or an API that consumes OAuth tokens, PKCE is no longer optional—it's a fundamental security requirement for modern applications.
Java Programming Basics – Variables, Loops, Methods, Classes, Files & Exception Handling (Related to Java Programming)
Variables and Data Types in Java:
This topic explains how variables store data in Java and how data types define the kind of values a variable can hold, such as numbers, characters, or text. Java includes primitive types like int, double, and boolean, which are essential for storing and managing data in programs. (GeeksforGeeks)
Read more: https://macronepal.com/blog/variables-and-data-types-in-java/
Basic Input and Output in Java:
This lesson covers how Java programs receive input from users and display output using tools like Scanner for input and System.out.println() for output. These operations allow interaction between the program and the user.
Read more: https://macronepal.com/blog/basic-input-output-in-java/
Arithmetic Operations in Java:
This guide explains mathematical operations such as addition, subtraction, multiplication, and division using operators like +, -, *, and /. These operations are used to perform calculations in Java programs.
Read more: https://macronepal.com/blog/arithmetic-operations-in-java/
If-Else Statement in Java:
The if-else statement allows programs to make decisions based on conditions. It helps control program flow by executing different blocks of code depending on whether a condition is true or false.
Read more: https://macronepal.com/blog/if-else-statement-in-java/
For Loop in Java:
A for loop is used to repeat a block of code a specific number of times. It is commonly used when the number of repetitions is known in advance.
Read more: https://macronepal.com/blog/for-loop-in-java/
Method Overloading in Java:
Method overloading allows multiple methods to have the same name but different parameters. It improves code readability and flexibility by allowing similar tasks to be handled using one method name.
Read more: https://macronepal.com/blog/method-overloading-in-java-a-complete-guide/
Basic Inheritance in Java:
Inheritance is an object-oriented concept that allows one class to inherit properties and methods from another class. It promotes code reuse and helps build hierarchical class structures.
Read more: https://macronepal.com/blog/basic-inheritance-in-java-a-complete-guide/
File Writing in Java:
This topic explains how to create and write data into files using Java. File writing is commonly used to store program data permanently.
Read more: https://macronepal.com/blog/file-writing-in-java-a-complete-guide/
File Reading in Java:
File reading allows Java programs to read stored data from files. It is useful for retrieving saved information and processing it inside applications.
Read more: https://macronepal.com/blog/file-reading-in-java-a-complete-guide/
Exception Handling in Java:
Exception handling helps manage runtime errors using tools like try, catch, and finally. It prevents programs from crashing and allows safe error handling.
Read more: https://macronepal.com/blog/exception-handling-in-java-a-complete-guide/
Constructors in Java:
Constructors are special methods used to initialize objects when they are created. They help assign initial values to object variables automatically.
Read more: https://macronepal.com/blog/constructors-in-java/
Classes and Objects in Java:
Classes are blueprints used to create objects, while objects are instances of classes. These concepts form the foundation of object-oriented programming in Java.
Read more: https://macronepal.com/blog/classes-and-object-in-java/
Methods in Java:
Methods are blocks of code that perform specific tasks. They help organize programs into smaller reusable sections and improve code readability.
Read more: https://macronepal.com/blog/methods-in-java/
Arrays in Java:
Arrays store multiple values of the same type in a single variable. They are useful for handling lists of data such as numbers or names.
Read more: https://macronepal.com/blog/arrays-in-java/
While Loop in Java:
A while loop repeats a block of code as long as a given condition remains true. It is useful when the number of repetitions is not known beforehand.
Read more: https://macronepal.com/blog/while-loop-in-java/