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.
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.
Consent returns access_denied
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:
a33213bfeat: configure token policy and issuer
Check out the last one to see the project exactly as this part leaves it:
git checkout a33213b
References
- PKCE — RFC 7636: https://www.rfc-editor.org/rfc/rfc7636
- OAuth Security BCP — RFC 9700: https://www.rfc-editor.org/rfc/rfc9700
- OAuth 2.1 draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/
- Spring Authorization Server protocol endpoints: https://docs.spring.io/spring-authorization-server/reference/protocol-endpoints.html
Next: replace in-memory OAuth state with PostgreSQL and Flyway migrations.