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=S256

Example 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-mJ92K5qZvbMCTr9g5gtWfH5zR2Rr8Y

Example 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.