Authorization Code Flow with PKCE
The Flow
This is the flow behind every "Log in" button that sends you to another site and back. PKCE (Proof Key for Code Exchange, said "pixie") stops an attacker who steals the authorization code from using it.
Browser / React app ShopEase client Authorization Server Order API
(user agent) (Spring app or SPA) (Keycloak / Google) (resource server)
| | | |
| 1. click "Login" | | |
|------------------------------>| | |
| | 2. create code_verifier (random) |
| | code_challenge = BASE64URL(SHA256(verifier)) |
| | state = random, nonce = random |
| 3. 302 redirect to /authorize?response_type=code&client_id=shopease-web |
| &redirect_uri=...&scope=openid orders.read&state=..&code_challenge=..& |
| code_challenge_method=S256 |
|<------------------------------| | |
|-------------------------------------------------------->| 4. user logs in, |
| | | approves scopes |
| 5. 302 redirect_uri?code=AUTH_CODE&state=.. | |
|<--------------------------------------------------------| |
|------------------------------>| 6. check state matches | |
| | 7. POST /token code + code_verifier |
| |------------------------->| 8. SHA256(verifier) |
| | | == stored challenge? |
| | 9. access_token, id_token, refresh_token |
| |<-------------------------| |
| | 10. GET /api/orders Authorization: Bearer access_token
| |---------------------------------------------------->|
| | | 11. verify signature, |
| | 12. 200 OK [orders] | iss, aud, exp, scope |
| |<----------------------------------------------------|Step 3: The Authorization Request
Authorize URL
The client redirects the browser to the authorization server. Line breaks are added for reading:
GET https://auth.shopease.in/realms/shopease/protocol/openid-connect/auth
?response_type=code
&client_id=shopease-web
&redirect_uri=https%3A%2F%2Fshopease.in%2Fcallback
&scope=openid%20profile%20email%20orders.read
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=fsvuGwC6jZ1S5fS6rRWxoYbRArpX776H68sqheFaMCg
&code_challenge_method=S256Example output
HTTP/1.1 302 Found Location: https://shopease.in/callback?code=4f2d1c9e-7a1b-4c1e-9a0d-2b1f7d9c3e55&state=af0ifjsldkj
Step 7: Exchanging the Code
Token request
The client sends the code and the original code_verifier. Only the app that started the flow knows the verifier:
curl -X POST https://auth.shopease.in/realms/shopease/protocol/openid-connect/token \
-d grant_type=authorization_code \
-d client_id=shopease-web \
-d code=4f2d1c9e-7a1b-4c1e-9a0d-2b1f7d9c3e55 \
-d redirect_uri=https://shopease.in/callback \
-d code_verifier=dBjftJeZ4CVP-mJ92K5qZvbMCTr9g5gtWfH5zR2Rr8YExample output
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNoLTEifQ.eyJpc3Mi...",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3...",
"token_type": "Bearer",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJzaG9wZWFzZS13ZWIi...",
"scope": "openid profile email orders.read"
}Tip: If an attacker intercepts code=... (for example through a malicious browser extension or a custom URL scheme on mobile), the code is useless without the verifier, which never left the client.
State and Redirect URI Checks
Two more checks protect the flow. state is a random value the client stores before redirecting and compares when the user comes back (stops CSRF on the login). The authorization server must compare the redirect_uri exactly, never with "starts with":
Example
Why exact matching matters:
import java.util.List;
public class Main {
static final String REGISTERED = "https://shopease.in/callback";
static boolean prefixCheck(String uri) { return uri.startsWith("https://shopease.in"); } // WRONG
static boolean exactCheck(String uri) { return uri.equals(REGISTERED); } // RIGHT
static boolean stateMatches(String sentState, String returnedState) {
return returnedState != null && sentState.equals(returnedState);
}
public static void main(String[] args) {
List<String> candidates = List.of(
"https://shopease.in/callback",
"https://shopease.in.evil.com/callback",
"https://[email protected]/callback",
"https://shopease.in/callback/../../steal");
for (String uri : candidates) {
System.out.printf("%-42s prefix=%-5s exact=%s%n", uri, prefixCheck(uri), exactCheck(uri));
}
String sent = "af0ifjsldkj";
System.out.println("state ok (same) : " + stateMatches(sent, "af0ifjsldkj"));
System.out.println("state ok (forged) : " + stateMatches(sent, "attacker-state"));
System.out.println("state ok (missing) : " + stateMatches(sent, null));
}
}Output
https://shopease.in/callback prefix=true exact=true https://shopease.in.evil.com/callback prefix=true exact=false https://[email protected]/callback prefix=true exact=false https://shopease.in/callback/../../steal prefix=true exact=false state ok (same) : true state ok (forged) : false state ok (missing) : false
Note: Spring Security's oauth2Login creates and checks state, nonce and PKCE for you. Keycloak and Spring Authorization Server match redirect URIs exactly. Avoid wildcard redirect URIs in Keycloak for production clients.