OAuth in Plain Java
GitHub: github.com/mnafshin/oauth
Why this post exists
Spring Security hides PKCE, state, discovery, and token exchange behind auto-configuration. That is good for productivity — bad for interviews and incident debugging.
Here we implement Authorization Code + PKCE with Java's built-in HttpClient and a minimal embedded HTTP server (or servlet). No Spring Security.
After this post, Spring's magic becomes named steps you already understand from the Authorization Code + PKCE walkthrough.
Demo repo
The oauth-demo monorepo ships a runnable module at modules/plain-java:
make start-keycloak
make start-plain-java
# or:
# docker compose -f modules/keycloak/docker-compose.yml up -d
# ./gradlew :plain-java:run
| Setting | Value |
|---|---|
| App URL | http://localhost:8082 |
| Redirect URI | http://localhost:8082/callback |
| Client | orders-web / orders-web-secret (same Keycloak realm as the Thymeleaf Spring Boot post) |
| Test user | alice / password |
Visit /dashboard — the app requires the app-user client role on orders-web, same as the Thymeleaf demo on port 8080. The SPA BFF demo (post 9) uses a separate Keycloak client spa-bff on port 8083.
Architecture
We store:
state+code_verifierin server-side session (in-memoryConcurrentHashMapfor demo)Session ID in httpOnly cookie
Step 1 — PKCE and state utilities
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public final class Pkce {
private static final SecureRandom RANDOM = new SecureRandom();
public static String randomUrlSafe(int bytes) {
byte[] buf = new byte[bytes];
RANDOM.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
public static String codeChallengeS256(String verifier) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
Step 2 — Build authorization URL
public URI buildAuthorizeUrl(OidcDiscovery doc, String clientId, String redirectUri,
String scope, String state, String codeChallenge) {
String query = "response_type=code"
+ "&client_id=" + urlEncode(clientId)
+ "&redirect_uri=" + urlEncode(redirectUri)
+ "&scope=" + urlEncode(scope)
+ "&state=" + urlEncode(state)
+ "&code_challenge=" + urlEncode(codeChallenge)
+ "&code_challenge_method=S256";
return URI.create(doc.authorizationEndpoint() + "?" + query);
}
Fetch OidcDiscovery once from /.well-known/openid-configuration (parse JSON with Jackson).
public record OidcDiscovery(
String issuer,
String authorizationEndpoint,
String tokenEndpoint,
String jwksUri
) {}
Step 3 — Token exchange
public TokenResponse exchangeCode(OidcDiscovery doc, String clientId, String clientSecret,
String redirectUri, String code, String codeVerifier)
throws IOException, InterruptedException {
String body = "grant_type=authorization_code"
+ "&code=" + urlEncode(code)
+ "&redirect_uri=" + urlEncode(redirectUri)
+ "&client_id=" + urlEncode(clientId)
+ "&code_verifier=" + urlEncode(codeVerifier);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(doc.tokenEndpoint()))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", basicAuth(clientId, clientSecret))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Token error: " + response.body());
}
return parseTokenResponse(response.body());
}
Step 4 — Validate ID token (do not skip)
public JWTClaimsSet validateIdToken(String idTokenJwt, OidcDiscovery doc, String clientId) {
// Use com.nimbusds:nimbus-jose-jwt
// 1. Parse JWT and verify RS256 signature against JWKS from doc.jwksUri() (cache keys)
// 2. require iss == doc.issuer()
// 3. require aud contains clientId
// 4. require exp in future (with clock skew)
return claims;
}
Do not parse the payload with Base64.decode and call it a day.
Step 5 — /login handler (conceptual)
void handleLogin(HttpExchange exchange, HttpSession session) throws IOException {
String state = Pkce.randomUrlSafe(32);
String verifier = Pkce.randomUrlSafe(64);
String challenge = Pkce.codeChallengeS256(verifier);
session.put("oauth_state", state);
session.put("code_verifier", verifier);
URI authorize = buildAuthorizeUrl(discovery, CLIENT_ID, REDIRECT_URI,
"openid profile email", state, challenge);
exchange.getResponseHeaders().add("Location", authorize.toString());
exchange.sendResponseHeaders(302, -1);
}
Step 6 — /callback handler
void handleCallback(HttpExchange exchange, HttpSession session) throws Exception {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
if (!params.get("state").equals(session.get("oauth_state"))) {
sendError(exchange, 400, "Invalid state");
return;
}
TokenResponse tokens = exchangeCode(discovery, CLIENT_ID, CLIENT_SECRET,
REDIRECT_URI, params.get("code"), session.get("code_verifier"));
JwtClaimsSet idToken = validateIdToken(tokens.idToken(), discovery, CLIENT_ID);
if (!hasAppUserRole(idToken)) {
sendError(exchange, 403, "Missing app-user role");
return;
}
session.put("user", idToken.getSubject());
session.put("username", idToken.getStringClaim("preferred_username"));
session.remove("oauth_state");
session.remove("code_verifier");
// Store refresh token server-side only if you need offline access
redirect(exchange, "/dashboard");
}
What you feel when it works
Redirect URI must match character-for-character — one trailing slash breaks everything.
Clock skew — laptop vs Docker VM time drift causes
expvalidation failures.Client auth method — Keycloak may expect POST body secret vs Basic auth; match client config.
Session fixation — issue new session ID after login.
Comparison: plain Java vs Spring Security
| Concern | Plain Java | Spring Security |
|---|---|---|
| PKCE | You generate | Automatic |
| State | You store/verify | Automatic |
| Discovery | You fetch/cache | issuer-uri |
| Token refresh | You schedule | OAuth2AuthorizedClientManager |
| CSRF on forms | You add | Built-in |
| Lines of code | ~200–400 | ~30 config |
Use Spring in production unless you have a compelling reason not to.
Go deeper
Refresh token rotation
String body = "grant_type=refresh_token&refresh_token=" + urlEncode(storedRefresh);
// POST to token endpoint; replace stored refresh if server rotates
PAR (Pushed Authorization Requests)
POST authorize parameters to pushed_authorization_request_endpoint first; use returned request_uri in place of long query strings.
Embedding in non-servlet apps
Same flow works in Netty, Vert.x, or Lambda@Edge for BFF — HTTP redirects and server-side session storage are universal.
What you should know after this post
[ ] You can implement PKCE and state without a framework
[ ] You know ID token validation is mandatory
[ ] You appreciate what Spring Security automates
[ ] You can run the plain Java demo against local Keycloak (
make start-plain-java)