Securing Mobile and SPAs: Implementing PKCE for Public Clients in Java

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:

  1. Code Verifier: The client generates a cryptographically random secret
  2. Code Challenge: The client creates a transformed version of the verifier (usually SHA256 hashed)
  3. Authorization Request: The client sends the challenge (not the verifier) with the authorization request
  4. Token Request: When exchanging the code for tokens, the client sends the original verifier
  5. 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

  1. No Client Secret Required: Public clients cannot securely store secrets
  2. Authorization Code Interception Protection: Even if the code is intercepted, it's useless without the verifier
  3. Mandatory for OAuth 2.1: PKCE is required in the upcoming OAuth 2.1 specification
  4. Native App Security: Protects against malicious apps on the same device
  5. 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 VectorPKCE MitigationImplementation
Authorization code interceptionCode verifier required✅ PkceAuthorizationCodeServices
CSRF on redirect URIState parameter✅ PkceOAuth2Client with state
Code verifier brute forceHigh entropy (256 bits)✅ SecureRandom 32 bytes
Replay attacksOne-time use verifier✅ PkceReplayPrevention
Downgrade attackEnforce 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper