Authorization Code and PKCE, by hand

Run the full browser flow with curl, inspect the tokens, rotate a refresh token, and watch PKCE reject a wrong verifier.

  • Part 3
  • intermediate
  • about 40 minutes

You will build

a complete Authorization Code + PKCE exchange driven from the terminal, with refresh-token rotation proven

You will understand

every value in the flow, what the callback page is really telling you, and why authorization codes are single-use

  • no new code, one token policy

Before adding persistence, we should prove that the protocol works end to end.

This part intentionally performs the flow manually. Browser tooling and OAuth clients can hide important details; curl, shell variables, and a visible redirect make the protocol concrete.

The flow we will execute

sequenceDiagram
    participant B as Browser/User
    participant AS as Authorization Server :9000
    participant CB as Callback :8081

    B->>AS: GET /oauth2/authorize + challenge
    AS->>B: Login + consent
    AS-->>CB: ?code=...&state=...
    Note over CB: No app needs to be running
    B->>AS: POST /oauth2/token + code + verifier
    AS-->>B: access_token + refresh_token + id_token (OIDC)

Step 1 — Generate PKCE verifier and challenge

Open a terminal and keep it open for the entire flow.

CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CODE_CHALLENGE=$(
  printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary \
  | openssl base64 -A \
  | tr '+/' '-_' \
  | tr -d '='
)

Inspect them:

echo "$CODE_VERIFIER"
echo "$CODE_CHALLENGE"

The verifier stays with the client. Only the challenge travels in the authorization request.

Step 2 — Open the authorization request

On macOS:

open "http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A8081%2Fcallback&scope=openid%20profile%20read&state=test123&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"

Log in with the development user:

user
password

Approve the requested scopes.

Step 3 — Understand the “failed” callback page

The authorization server redirects to something like:

http://127.0.0.1:8081/callback?code=abc...&state=test123

If nothing is running on 8081, your browser may display an unmatched route or connection error.

That is not an OAuth failure.

We registered a callback URI solely so we can inspect the authorization response. Copy the code parameter from the browser address bar.

CODE='paste-code-here'

Step 4 — Exchange the code

TOKEN_RESPONSE=$(curl -s \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8081/callback" \
  --data-urlencode "code_verifier=$CODE_VERIFIER")

Inspect:

echo "$TOKEN_RESPONSE" | jq

A successful response includes an access token and refresh token. If openid was authorized, it also includes an ID Token.

Extract values:

ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')
ID_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.id_token')

export ACCESS_TOKEN REFRESH_TOKEN ID_TOKEN

Step 5 — Decode the JWT payload

Do not confuse decoding with verification. We are only looking at the claims.

python3 - <<'PY'
import os, json, base64

token = os.environ["ACCESS_TOKEN"]
parts = token.split(".")
if len(parts) != 3:
    raise SystemExit("ACCESS_TOKEN is not a JWT")

payload = parts[1]
payload += "=" * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY

Look for claims such as:

{
  "iss": "http://localhost:9000",
  "sub": "user",
  "aud": "demo-client",
  "scope": ["openid", "profile", "read"],
  "exp": 0,
  "iat": 0
}

Later we will add a custom roles claim.

Step 6 — Refresh the access token

REFRESH_RESPONSE=$(curl -s \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/token \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN")
echo "$REFRESH_RESPONSE" | jq

Because we configured:

.reuseRefreshTokens(false)

the response should contain a new refresh token.

Compare:

NEW_REFRESH_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.refresh_token')

printf 'old: %s\n' "$REFRESH_TOKEN"
printf 'new: %s\n' "$NEW_REFRESH_TOKEN"

They should differ.

Why rotate refresh tokens?

Refresh tokens live much longer than access tokens. If a stolen refresh token can be reused indefinitely, an attacker can maintain access even while access tokens expire normally.

Rotation lets an authorization server detect or contain replay patterns more effectively. RFC 9700 discusses refresh-token replay protection and recommends sender-constrained refresh tokens or refresh-token rotation for public clients.

Negative test — wrong verifier

Start a fresh authorization request, but redeem the resulting code with a different verifier.

The token endpoint should reject it, normally with:

{
  "error": "invalid_grant"
}

That is PKCE doing its job.

Negative test — reuse an authorization code

Authorization codes are single-use.

Redeem the same successful CODE again:

curl -i \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8081/callback" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"

Expect invalid_grant.

Figure: credentials through the flow

flowchart TD
    V[code_verifier] --> H[SHA-256]
    H --> CH[code_challenge]
    CH --> AR[Authorization Request]
    AR --> CODE[Authorization Code]
    V --> TR[Token Request]
    CODE --> TR
    TR --> AT[Access Token]
    TR --> RT[Refresh Token]
    TR --> ID[ID Token when OIDC]

Troubleshooting

invalid_request involving client_id

Verify that demo-client is actually registered and that the authorization URL has not been malformed by shell quoting.

On the consent page, ensure the required scopes are approved. Submitting the form with no approved scopes can legitimately produce an authorization denial.

id_token is null

First inspect:

echo "$TOKEN_RESPONSE" | jq -r '.scope'

The authorized scopes must contain:

openid

An OAuth-only authorization does not produce an OpenID Connect ID Token.

What have we proven?

Authorization endpoint       ✓
Form login                   ✓
Consent                      ✓
Authorization Code           ✓
PKCE                         ✓
Token endpoint               ✓
JWT access token             ✓
Refresh token                ✓
Refresh-token rotation       ✓
OpenID Connect trigger       ✓

But everything is still in memory. Restarting the process loses clients and authorization state, and the signing key changes. That is what we fix next.

The code

This part corresponds to these commits in the repository:

  • a33213b feat: configure token policy and issuer

Check out the last one to see the project exactly as this part leaves it:

git checkout a33213b

References

Next: replace in-memory OAuth state with PostgreSQL and Flyway migrations.