IDENTITY BYTES
Senior IAM Architect Track / Phase 1: Core IAM Platform

LAB 04: OIDC Flows End to End
with Authorization Code and PKCE

Retire the password grant you used in Lab 02 and build the flow the modern web actually runs on. You will drive the authorization code flow with PKCE by hand, capture the code as it crosses the browser, exchange it for tokens, and prove why an intercepted code is worthless without the secret only your client knows.

IB-SIA-04 Intermediate Est. 3 to 3.5 hours OAuth 2.1 / PKCE / Authorization Code / OIDC
Section 1

Lab Metadata

AttributeValue
Lab IDIB-SIA-04
TrackIdentity Bytes Senior IAM Architect Track (36 lab curriculum)
DifficultyIntermediate (requires IB-SIA-01 and IB-SIA-02; IB-SIA-03 recommended)
Core technologiesKeycloak 26.0 (from IB-SIA-02), OpenLDAP (from IB-SIA-01), curl, jq, openssl, Python 3 http.server, browser developer tools
Protocols and standardsOAuth 2.0 (RFC 6749), OAuth 2.1 draft, PKCE (RFC 7636), OIDC Core 1.0, refresh token rotation, Pushed Authorization Requests (RFC 9126) context
Builds onIB-SIA-02 (the northgate realm and hr-portal client are reconfigured for the code flow)
Feeds intoIB-SIA-05 (MFA lands inside this flow), IB-SIA-10 (OAuth 2.1 hardening: DPoP, PAR), IB-SIA-11 (token exchange)
Section 2

Lab Title and Description

The Flow the Web Runs On: Authorization Code with PKCE, Step by Step

In Lab 02 you obtained tokens with a single curl command using the password grant, and the lab warned you twice that no real application should work that way. This lab makes good on that warning. The password grant hands the user's credentials to the application, which is the exact anti pattern the whole IdP model exists to remove; it also bypasses MFA, adaptive policy, and the IdP's own login page. Every current standard, OAuth 2.1 and the OAuth Security Best Current Practice, tells you to delete it.

The replacement is the authorization code flow with PKCE, and it is what sits behind essentially every browser and mobile login you use: your bank, your email, your government tax account. The application never sees the password. Instead it sends the user to the IdP, the IdP authenticates them on its own hardened page and hands back a short lived, single use authorization code through the browser, and the application then exchanges that code for tokens on a back channel the browser never sees. PKCE, pronounced pixy, binds the code to the specific client instance that started the flow, so a code stolen in transit cannot be redeemed by anyone else.

You will run this flow the hard way first, constructing each URL and cryptographic value by hand with curl and openssl so nothing is hidden, capturing the code in your browser's address bar, and exchanging it at the token endpoint. Then you will run it the real way, with a tiny local application that completes the round trip, and you will attack it: replay a code, tamper a PKCE verifier, and watch the IdP refuse. By the end you will contrast this flow directly against the SAML assertion flow from Lab 03, two protocols solving one problem, and know exactly why the industry standardised on this one for new build. Estimated completion time is 3 to 3.5 hours.

Section 3

Prerequisites

3.1 Prior labs required

LabWhy it is required
IB-SIA-01OpenLDAP remains the password store behind the IdP's login page
IB-SIA-02The northgate realm and the hr-portal client are the starting point; you reconfigure the client for the code flow and reuse curl and jq skills
IB-SIA-03 (recommended)Section 8 contrasts this flow against the SAML assertion you captured there; the comparison is far richer if you have done it
Restart and verify the environment
# Bring the track containers up
docker start ib-openldap ib-keycloak ib-phpldapadmin

# Confirm the northgate realm and its token endpoint answer
curl -s http://localhost:8081/realms/northgate/.well-known/openid-configuration \
  | jq -r '.authorization_endpoint, .token_endpoint'
# Expected: two URLs under /realms/northgate/protocol/openid-connect/

Both endpoints must print. If the realm is missing, rebuild Lab 02 Phases 1 to 4 first.

3.2 System requirements

ResourceMinimumRecommended
Operating systemUbuntu 22.04, macOS 13, or Windows 11 with WSL2Ubuntu 22.04 LTS
RAM6 GB8 GB
Disk12 GB free20 GB free
NetworkLocal port 9090 must be free for the demo application in Phase 4.

3.3 Required tools and versions

ToolVersionPurpose
Docker, curl, jqAs installed in Labs 01 and 02Container control, HTTP requests, JSON and JWT decoding
openssl1.1.1 or laterGenerates the PKCE verifier and its SHA-256 challenge
Python 33.8 or laterRuns the minimal callback application in Phase 4
INFOYou almost certainly have openssl and Python 3 already. Confirm with openssl version and python3 --version. Both ship by default on Ubuntu and macOS, and inside WSL2 Ubuntu.

3.4 Verification of tools

Verify openssl and Python 3
openssl version
python3 --version
# Expected: OpenSSL 3.x (or 1.1.1) and Python 3.8+
VERIFICATIONNorthgate endpoints answering, openssl and Python 3 present: you are ready. Keep your Keycloak admin password, the hr-portal client secret from Lab 02, and the LDAP user password to hand. If you rotated or lost the client secret, regenerate it under Clients, hr-portal, Credentials.
Section 4

Real World Problem Statement

This lab solves the problem hiding inside Lab 02's convenience: the application must never handle the password, and the mechanism that removes it must survive a hostile network. The password grant fails both tests, because the credential passes through the application, and any flow that returns tokens directly through the browser exposes them to redirect interception, malicious browser extensions, and referrer leakage. These are not theoretical: the authorization code interception attack on mobile and single page applications is exactly what PKCE was published to stop, and it is why OAuth 2.1 removes the implicit flow and the password grant from the specification entirely.

The authorization code flow with PKCE is the answer the entire industry converged on. Credentials stay at the IdP. What crosses the browser is a single use code that is useless on its own, because redeeming it requires proof of possession of a secret the legitimate client generated and never transmitted. This is the default for web applications, the mandatory pattern for mobile and single page applications that cannot hold a static secret, and the flow every modern SDK implements by default. A senior IAM architect signs off application onboarding against this pattern and rejects designs that deviate from it.

Why it matters, across four dimensions

Risk

The password never reaches the application, and the code that does cross the browser is single use, short lived, and cryptographically bound to one client. An attacker who captures the code in transit still cannot exchange it.

Compliance

Delegated authentication through the IdP is what lets MFA and adaptive policy apply uniformly, satisfying PCI DSS 4.0 strong authentication and NIST SP 800-63B AAL2 at one enforcement point rather than per application.

Productivity

Every SDK and framework implements this flow, so onboarding a new application is configuration, not cryptography. Refresh tokens keep users signed in without re-entering credentials.

Security Posture

Short lived access tokens plus rotating refresh tokens are the token discipline of Zero Trust, and this flow is the foundation the OAuth 2.1 hardening in IB-SIA-10 builds on with DPoP and PAR.

Concrete scenario

Northgate Financial's security review of the Lab 02 HR portal integration returns one blocking finding: the portal uses the resource owner password grant, which is prohibited by the corporate authentication standard and by OAuth 2.1. The remediation is mandatory before go live. Your task in this lab is to re-platform the HR portal onto the authorization code flow with PKCE, prove the round trip works with a real redirect based login, demonstrate that credentials never touch the application, enable refresh token rotation so long sessions do not depend on long lived tokens, and produce captured evidence of the code exchange and its security properties for the reviewer to sign off.

Section 5

Skills Mapped to Production Solutions

Skill LearnedReal World Enterprise Application
Driving the authorization code flow manuallyUnderstanding what every OIDC SDK does under the hood, which is what separates engineers who configure SSO from those who can debug it when the SDK abstraction leaks
Generating and using PKCE verifier and challengeSecuring mobile and single page application logins, the mandatory pattern for public clients across the entire modern application estate
Configuring public versus confidential clients correctlyOnboarding decisions for every new application: server side web app, SPA, mobile app, or CLI each demand a different client type and flow
Capturing the authorization code and exchanging it for tokensFrontline troubleshooting of redirect_uri mismatches and code exchange failures, the two most common OIDC integration tickets
Using the state and nonce parametersDefending against CSRF and token replay in real integrations, the checks security reviewers look for in an application's OIDC implementation
Enabling and observing refresh token rotationDesigning long lived sessions safely, and detecting token theft through rotation breakage, a modern SOC signal
Contrasting OIDC and SAML flows from captured evidenceThe build versus integrate and protocol selection decisions a senior architect defends in design review and interview
Section 6

Architecture Overview

User Agent the browser: front channel, carries code + state only HR PORTAL confidential client verifier (kept secret) client_secret (back channel) KEYCLOAK realm: northgate /authorize stores challenge, issues code /token checks verifier vs challenge login page LDAP bind (Lab 01) refresh rotation new refresh each use 1. redirect to /authorize?code_challenge=...&state=... app first built the challenge 2. user logs in at IdP (password never leaves here) 3. redirect back with ?code=...&state=... 4. BACK CHANNEL: code + verifier + secret to /token 5. tokens returned (browser never sees them) The code crosses the browser. The tokens never do. That is the whole idea.

Component breakdown

ComponentPurposeTechnologyDeploymentPortsKey configuration
User agentFront channel carrier: relays the authorization request and the returned code, never the tokensAny browserHostn/aAddress bar and developer tools are your capture points
HR portal clientThe confidential application that starts the flow, holds the PKCE verifier and client secret, and exchanges the code on the back channelKeycloak OIDC client + a Python callback listenerReconfigured hr-portal; listener on host9090 (listener)Standard flow ON, Direct access grants OFF, PKCE code challenge method S256, redirect URI http://localhost:9090/callback
Keycloak authorization endpointAuthenticates the user, stores the PKCE challenge against the code, issues the single use codeKeycloak /authorizeExisting container8081Binds code to challenge, state, and redirect URI
Keycloak token endpointVerifies the client, matches the PKCE verifier to the stored challenge, and issues tokensKeycloak /tokenExisting container8081Rejects code reuse, verifier mismatch, and redirect URI mismatch
OpenLDAPThe password store behind the IdP login pageosixia/openldap (Lab 01)Existing389Unchanged

Data flow

  1. The client builds a challenge and redirects: the HR portal generates a random PKCE verifier, hashes it to a challenge, stores the verifier locally, and sends the browser to /authorize carrying the challenge, a random state, and its redirect URI. Why: the challenge commits the client to a secret it has not revealed, so the code that follows can only be redeemed by whoever holds the matching verifier.
  2. The IdP authenticates the user: Keycloak shows its login page and validates the password by LDAP bind. Why: credentials reach the IdP alone, which is the entire security case for the flow, and it is where MFA in the next lab attaches.
  3. The IdP returns a code through the browser: Keycloak redirects back to the client's registered URI with a single use authorization code and the echoed state. Why: the code is inert in transit; unlike a token, capturing it grants nothing without the verifier.
  4. The client exchanges the code on the back channel: the HR portal calls /token directly, presenting the code, the original verifier, and its client secret. Why: this server to server call never touches the browser, so the tokens are never exposed to the front channel.
  5. The IdP verifies and issues tokens: Keycloak confirms the client secret, hashes the presented verifier, matches it to the stored challenge, checks the redirect URI, and only then returns the ID, access, and refresh tokens. Why: three independent bindings, secret, PKCE, and redirect URI, must all hold, which is what makes the flow robust against interception.

Security considerations

ControlIn this lab
Credential confinementThe password is entered only on Keycloak's page; the application provably never receives it, unlike the Lab 02 password grant
Code interception resistancePKCE S256 binds the code to the client instance; a captured code without the verifier is refused at the token endpoint (proven in Section 8)
CSRF and replay defenceThe state parameter is generated, echoed, and checked; nonce ties the ID token to the request; codes are single use and short lived
Session longevity without long lived tokensRefresh token rotation issues a new refresh token on each use and invalidates the old, so a leaked refresh token is detectable and self limiting
Section 7

Step by Step Implementation

Phase 1: Reconfigure the HR Portal Client for the Code Flow

Step 1.1: Turn off the password grant and turn on PKCE

Purpose

Bring the Lab 02 client into line with the corporate standard: standard flow only, direct access grants off, PKCE required.

Context

You are performing the exact remediation the scenario's security review demanded. Disabling direct access grants removes the password grant capability entirely, so even a misconfigured integration cannot fall back to it. Requiring PKCE means Keycloak will reject any authorization request that arrives without a challenge, closing the flow to older, weaker patterns.

Actions in the admin console (realm: northgate)
  1. Go to Clients → hr-portal → Settings.
  2. Under Capability config: Client authentication ON (still confidential), Standard flow ON, Direct access grants OFF, Implicit flow OFF, Service accounts roles OFF.
  3. Confirm Valid redirect URIs includes http://localhost:9090/callback and http://localhost:9090/*. Set Web origins to http://localhost:9090. Click Save.
  4. Open the Advanced tab, find Proof Key for Code Exchange Code Challenge Method, and set it to S256. Click Save.
VERIFICATIONProve the password grant is gone: run the Lab 02 Step 5.1 token request again (grant_type=password). Expected now: {"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}. The old door is bricked up, which is the finding closed.
What just happened?The same client that handed over Amina's password in Lab 02 will now refuse to. From here, the only way to get a token for a user is to send them through the IdP's own login page, which is precisely the control the security standard requires.

Phase 2: Run the Flow by Hand, Part One: the Authorization Request

Step 2.1: Generate the PKCE verifier and challenge

Purpose

Create the two linked cryptographic values at the heart of the flow, so you understand exactly what an SDK generates for you.

Context

The verifier is a high entropy random string the client keeps. The challenge is its SHA-256 hash, base64url encoded, which the client sends to the IdP. Because a hash is one way, anyone who sees the challenge cannot derive the verifier, but the IdP can later confirm that a presented verifier hashes to the stored challenge. That asymmetry is the whole trick.

Generate and store the PKCE values
# Work in a scratch directory
mkdir -p ~/ib-sia-04 && cd ~/ib-sia-04

# VERIFIER: 32 random bytes, base64url encoded, no padding.
# This is the secret the client keeps and never sends in the front channel.
VERIFIER=$(openssl rand -base64 96 | tr -d '\n' | tr '+/' '-_' | tr -d '=' | cut -c1-64)
echo "$VERIFIER" > verifier.txt
echo "verifier: $VERIFIER"

# CHALLENGE: SHA-256 of the verifier, raw binary, base64url encoded, no pad.
# This is what the client sends to /authorize.
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
echo "$CHALLENGE" > challenge.txt
echo "challenge: $CHALLENGE"

# STATE: random anti-CSRF value the IdP will echo back unchanged
STATE=$(openssl rand -hex 16)
echo "$STATE" > state.txt
echo "state: $STATE"
Expected outcome

Three values printed and saved: a 64 character verifier, a 43 character challenge, and a 32 character state. Keep this terminal open; the same shell variables carry into Phase 3.

VERIFICATIONConfirm the challenge really is the hash of the verifier by recomputing it: printf '%s' "$(cat verifier.txt)" | openssl dgst -sha256 -binary | openssl base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=' ; cat challenge.txt. The two printed lines must be identical. This is the exact check Keycloak performs at the token endpoint.

Step 2.2: Build the authorization URL and log in

Purpose

Assemble the /authorize request carrying the challenge and state, open it in a browser, authenticate, and capture the returned code.

Construct and open the authorization URL
# Build the authorization request. response_type=code selects the
# authorization code flow; the challenge commits us to the verifier.
AUTH_URL="http://localhost:8081/realms/northgate/protocol/openid-connect/auth"
AUTH_URL="$AUTH_URL?client_id=hr-portal"
AUTH_URL="$AUTH_URL&response_type=code"
AUTH_URL="$AUTH_URL&scope=openid%20profile%20email"
AUTH_URL="$AUTH_URL&redirect_uri=http://localhost:9090/callback"
AUTH_URL="$AUTH_URL&code_challenge=$(cat challenge.txt)"
AUTH_URL="$AUTH_URL&code_challenge_method=S256"
AUTH_URL="$AUTH_URL&state=$(cat state.txt)"

echo "$AUTH_URL"
# Copy the printed URL into a private browser window.

Nothing is listening on port 9090 yet, so after you log in the browser will show a connection error. That is expected and useful: the code you need is sitting in the address bar of that failed page.

Actions
  1. Paste the URL into a private browser window. Keycloak's login page appears.
  2. Sign in as asmith with the Lab 01 <USER_PASSWORD>.
  3. The browser redirects to http://localhost:9090/callback?code=...&state=... and shows a connection refused error. Copy the entire URL from the address bar.
Expected outcome

A callback URL containing a long code value and a state that matches the one you generated.

VERIFICATIONExtract and compare the state. Paste the callback URL into a variable and check: CALLBACK='PASTE_URL_HERE'; echo "$CALLBACK" | grep -oP 'state=\K[^&]+'; cat state.txt. The two must match exactly. A mismatch in a real client means a CSRF attempt and the flow must be aborted; checking state is not optional, it is the defence.
What just happened?Your password went to Keycloak and nowhere else. What came back to the client side is a code, not a token. If an attacker had been reading your browser traffic, they would now hold that code, and in the next step you will see why it does them no good.

Phase 3: Run the Flow by Hand, Part Two: the Token Exchange

Step 3.1: Exchange the code for tokens on the back channel

Purpose

Complete the flow as the application would: present the code, the verifier, and the client secret to the token endpoint, and receive tokens.

Redeem the authorization code
# Extract the code from the callback URL you copied.
CALLBACK='PASTE_THE_FULL_CALLBACK_URL_HERE'
CODE=$(echo "$CALLBACK" | grep -oP 'code=\K[^&]+')
echo "code: $CODE"

# Exchange it. Note all three proofs travel here on the BACK CHANNEL:
#   client_secret  proves which client (confidential)
#   code_verifier  proves possession of the PKCE secret
#   redirect_uri   must match the one used at /authorize exactly
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=authorization_code \
  -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" \
  -d code="$CODE" \
  -d redirect_uri=http://localhost:9090/callback \
  -d code_verifier="$(cat verifier.txt)" | jq . | tee tokens.json
Expected outcome

A token response with access_token, id_token, refresh_token, and "token_type": "Bearer", the same shape as Lab 02 but obtained without the application ever seeing a password.

VERIFICATIONDecode the ID token and confirm the user: jq -r '.id_token | split(".")[1] | @base64d | fromjson | {sub: .preferred_username, iss, aud, azp}' tokens.json. Expected: preferred_username asmith, iss the northgate realm, azp hr-portal. If you see invalid_grant with "Code not valid", the code has already been used or has expired (they last about a minute); rerun Phase 2 for a fresh one. If you see a PKCE error, the verifier and challenge were from different runs; regenerate both in Step 2.1.
What just happened?You completed the round trip. Keycloak checked three independent things before issuing tokens: that the client secret was right, that the SHA-256 of your verifier equalled the challenge it stored at /authorize, and that the redirect URI matched. Only with all three satisfied did tokens appear, and they came back on this back channel, never through the browser.

Phase 4: Run the Flow the Real Way with a Minimal Application

Step 4.1: Launch a callback listener that completes the exchange automatically

Purpose

Replace the manual copy and paste with a tiny real application, so you see the flow as a user experiences it: click, log in, land back logged in.

Context

This forty line Python program is not production code; it exists to make the round trip continuous and visible. It generates its own PKCE values, opens the authorization URL, receives the code on port 9090, exchanges it, and prints the decoded ID token in the browser. Reading it is worthwhile, because it is the smallest honest version of what every OIDC SDK does.

Create and run the demo application
cat > ~/ib-sia-04/app.py << 'EOF'
import http.server, urllib.parse, urllib.request, json, secrets, hashlib, base64, webbrowser

KC = "http://localhost:8081/realms/northgate/protocol/openid-connect"
CLIENT_ID = "hr-portal"
CLIENT_SECRET = "<CLIENT_SECRET>"   # paste the hr-portal secret
REDIRECT = "http://localhost:9090/callback"

def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
verifier = b64url(secrets.token_bytes(48))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state = secrets.token_hex(16)

auth = (f"{KC}/auth?client_id={CLIENT_ID}&response_type=code"
        f"&scope=openid%20profile%20email&redirect_uri={REDIRECT}"
        f"&code_challenge={challenge}&code_challenge_method=S256&state={state}")

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        q = urllib.parse.urlparse(self.path)
        if not q.path.startswith("/callback"):
            self.send_response(404); self.end_headers(); return
        p = urllib.parse.parse_qs(q.query)
        if p.get("state", [""])[0] != state:
            self.send_response(400); self.end_headers()
            self.wfile.write(b"STATE MISMATCH - possible CSRF, aborted"); return
        data = urllib.parse.urlencode({
            "grant_type": "authorization_code", "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET, "code": p["code"][0],
            "redirect_uri": REDIRECT, "code_verifier": verifier}).encode()
        tok = json.load(urllib.request.urlopen(f"{KC}/token", data))
        claims = json.loads(base64.urlsafe_b64decode(
            tok["id_token"].split(".")[1] + "=="))
        self.send_response(200); self.send_header("Content-Type","text/html"); self.end_headers()
        self.wfile.write(b"

Logged in via authorization code + PKCE

")
        self.wfile.write(json.dumps(
            {k: claims[k] for k in ("preferred_username","email","name","iss","aud")
             if k in claims}, indent=2).encode())
        self.wfile.write(b"
") def log_message(self, *a): pass print("Open this URL to begin:\n" + auth) webbrowser.open(auth) http.server.HTTPServer(("localhost", 9090), H).serve_forever() EOF # Paste your hr-portal client secret into CLIENT_SECRET, then run: cd ~/ib-sia-04 && python3 app.py

If you are on WSL2 or a headless VM where webbrowser.open cannot launch anything, copy the URL the script prints into a browser manually.

Expected outcome

A browser tab opens to the Keycloak login page. Sign in as asmith, and the tab returns showing "Logged in via authorization code + PKCE" and a small block of your claims. Stop the server with Ctrl C when done.

VERIFICATIONThe page shows preferred_username asmith and your email, with no password anywhere in the application's own inputs. You experienced the flow exactly as a real user does: one login at the IdP, then landed back at the application, authenticated.
PRODUCTION CONSIDERATIONReal applications never hand roll this. They use a vetted library, Keycloak's own adapters, or a reverse proxy such as oauth2-proxy, all of which implement state, nonce, PKCE, token validation, and refresh for you. The value of building it once by hand is that when the library misbehaves, you can read its logs and know what should be happening.

Phase 5: Refresh Token Rotation

Step 5.1: Enable rotation and observe a refresh token die on reuse

Purpose

Turn on refresh token rotation and prove that reusing an old refresh token breaks the whole chain, the property that makes long sessions safe.

Context

Access tokens are short lived by design, five minutes here. To keep a user signed in for hours without re-authenticating, the client silently swaps its refresh token for a new access token as needed. Rotation adds a critical safety property: each refresh also issues a brand new refresh token and invalidates the old one. If a stolen refresh token is used, either the thief or the legitimate client will present a now invalid token, and Keycloak revokes the entire session, turning theft into a detectable, self limiting event.

Actions in the admin console (realm: northgate)
  1. Go to Realm settings → Sessions → Revoke refresh token and switch it ON. Set Refresh token max reuse to 0. Click Save.
Demonstrate rotation and reuse detection
cd ~/ib-sia-04

# Capture the first refresh token from your Phase 3 exchange
RT1=$(jq -r '.refresh_token' tokens.json)

# USE 1: exchange it for new tokens. Note a NEW refresh token comes back.
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=refresh_token -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" -d refresh_token="$RT1" \
  | jq -r '.refresh_token' > rt2.txt
echo "got a new refresh token: $(cut -c1-24 rt2.txt)..."

# USE 2 (the attack): try to reuse the ORIGINAL refresh token again.
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=refresh_token -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" -d refresh_token="$RT1" | jq .
# Expected: {"error":"invalid_grant","error_description":"Token is not active"}

# And the rotation cascade: the NEW token is now also dead, because reuse
# of the old one triggered revocation of the whole chain.
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=refresh_token -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" -d refresh_token="$(cat rt2.txt)" | jq .
# Expected: invalid_grant as well. The session is gone; the user re-authenticates.
Expected outcome

Use 1 succeeds and returns a new refresh token. Reusing the original fails with invalid_grant, and the replacement token is dead too.

VERIFICATIONThe reuse attempt returns invalid_grant, and the follow up proves the whole chain was revoked. This is the behaviour a SOC watches for: a refresh reuse error is a strong signal that a refresh token leaked, because a correctly behaving client never reuses one.
What just happened?You watched token theft become self defeating. Without rotation, a stolen refresh token is a long lived master key. With rotation, the first illegitimate use collides with the legitimate one, and Keycloak responds by killing the session for everyone, converting a silent compromise into a loud, recoverable event.
Section 8

Testing and Validation

End to end scenario: the remediated HR portal

This confirms the security review finding is closed and the replacement flow holds.

Run the end to end validation
# STAGE 1: the prohibited flow is gone. Password grant must be refused.
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=password -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" \
  -d username=asmith -d password="<USER_PASSWORD>" | jq -r '.error'
# Expected: unauthorized_client

# STAGE 2: the approved flow works, via app.py (Phase 4). Log in as asmith.
# Expected: the app shows her claims; the app never saw her password.

# STAGE 3: PKCE is mandatory. Attempt /authorize WITHOUT a challenge.
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \
  "http://localhost:8081/realms/northgate/protocol/openid-connect/auth?client_id=hr-portal&response_type=code&scope=openid&redirect_uri=http://localhost:9090/callback&state=x"
# Expected: a redirect back carrying error=invalid_request (missing
# code_challenge), because the client requires PKCE.

Negative tests

Run the negative tests
# TEST N1: intercepted code without the verifier is worthless.
# Get a fresh code (Phase 2), then exchange it with a WRONG verifier:
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=authorization_code -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" -d code="$CODE" \
  -d redirect_uri=http://localhost:9090/callback \
  -d code_verifier="wrong-verifier-wrong-verifier-wrong-verifier-1234567" | jq .
# Expected: invalid_grant, "PKCE verification failed". THIS is the attack
# PKCE defeats: an attacker who sniffed the code cannot redeem it.

# TEST N2: a code is single use. Redeem a valid code correctly once
# (it succeeds), then run the SAME exchange again.
# Expected on the second run: invalid_grant, "Code not valid". Replay dead.

# TEST N3: redirect_uri must match the authorization request.
# With a fresh code, exchange using a different redirect_uri:
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=authorization_code -d client_id=hr-portal \
  -d client_secret="<CLIENT_SECRET>" -d code="$CODE" \
  -d redirect_uri=http://localhost:9090/evil \
  -d code_verifier="$(cat verifier.txt)" | jq -r '.error_description'
# Expected: an "Incorrect redirect_uri" style error. The URI is bound to
# the code; an attacker cannot divert the exchange to their own endpoint.

# TEST N4: confidential client still needs its secret.
# Exchange a fresh code with NO client_secret:
curl -s -X POST http://localhost:8081/realms/northgate/protocol/openid-connect/token \
  -d grant_type=authorization_code -d client_id=hr-portal -d code="$CODE" \
  -d redirect_uri=http://localhost:9090/callback \
  -d code_verifier="$(cat verifier.txt)" | jq -r '.error'
# Expected: invalid_client. Two independent proofs guard the exchange.

Protocol comparison: this flow versus the SAML flow from Lab 03

ConcernOIDC authorization code + PKCE (this lab)SAML 2.0 (Lab 03)
Credential the app receivesA code, then tokens on the back channelA signed assertion posted to the ACS
Token formatCompact JWT, base64url JSONVerbose XML assertion
Interception defencePKCE binds the code to the clientXML signature plus audience and time conditions
Identifier of the usersub claim<NameID> element
Who may consumeaud claim<AudienceRestriction>
Best fit todayNew build: web, SPA, mobile, APIExisting enterprise SaaS and legacy integrations

Having captured both a JWT and an assertion by hand, you can now answer the OIDC or SAML interview question from evidence rather than opinion: the same trust problem, one solved with a signed document, the other with a bound single use code, and the industry defaulting to the second for anything built this decade.

Common failure modes and solutions

SymptomLikely causeSolution
invalid_grant, "Code not valid"Code already used or expired (about 60 seconds)Rerun the authorization request for a fresh code and exchange promptly
invalid_grant, "PKCE verification failed"Verifier and challenge came from different runsRegenerate both together in Step 2.1 and use the same shell session
unauthorized_client at /tokenStandard flow disabled, or you sent grant_type=passwordConfirm Standard flow ON and use grant_type=authorization_code
Authorization request shows "Invalid parameter: redirect_uri"The redirect URI is not registered on the clientAdd the exact URI to Valid redirect URIs and match it in both the auth and token calls
app.py raises HTTP 400 from the token endpointCLIENT_SECRET placeholder not replaced, or port 9090 in usePaste the real secret; free port 9090 or change it consistently in the client and script
Refresh reuse test unexpectedly succeedsRevoke refresh token not enabled, or max reuse above 0Recheck Realm settings, Sessions; set Revoke refresh token ON and max reuse 0
Section 9

Security Analysis

What makes this implementation secure

What is intentionally simplified for the lab

Production hardening recommendations

RecommendationWhyCovered in
TLS everywhere, HSTS, secure and httpOnly cookiesWithout transport security the code and back channel are interceptable regardless of PKCEIB-SIA-17 to 19 (PKI phase)
Adopt Pushed Authorization Requests and consider JARMoves authorization parameters off the browser URL, reducing tampering and leakage surfaceIB-SIA-10 (OAuth 2.1 hardening)
Sender constrain tokens with DPoP or mTLSBinds tokens to a key so a stolen bearer token cannot be replayed by another partyIB-SIA-10
Replace the static client secret with private_key_jwt or mTLS client authRemoves the shared secret that ages and leaks; asymmetric client auth is the modern defaultIB-SIA-10
Enforce MFA and step up in the IdP login stepThe flow delegates authentication to the IdP precisely so this control applies to every client at onceIB-SIA-05 and IB-SIA-08
Tune access token lifetime, SSO idle and max, and refresh limits to a risk modelDefaults are a starting point; sensitive applications warrant shorter windowsIB-SIA-12 (session management controls)
Alert on refresh token reuse and PKCE failures in the SIEMBoth are strong signals of token theft or a misbehaving clientIB-SIA-35 (identity observability)
Section 10

Cleanup Instructions

Option A: pause the lab, keep everything (recommended)

Stop containers and the demo app
# Stop app.py with Ctrl C in its terminal, then:
docker stop ib-keycloak ib-openldap ib-phpldapadmin
# Resume later with:
docker start ib-openldap ib-keycloak ib-phpldapadmin

Option B: revert the Lab 04 client changes, keep the environment

Return hr-portal to a clean state and remove scratch files
# The flow changes live in Keycloak config. To revert for a fresh run:
#   Clients, hr-portal, Settings: leave Standard flow ON (it is correct),
#   and decide whether to re-disable Direct access grants (recommended OFF).
#   Realm settings, Sessions: Revoke refresh token can stay ON (good practice).

# Remove local scratch files, which contain a real code and tokens:
rm -rf ~/ib-sia-04
SECURITY WARNINGThe files in ~/ib-sia-04 include a real refresh token and decoded claims. Delete them, or at minimum never commit them. Section 12 covers what is safe to publish.
VERIFICATIONAfter cleanup the northgate realm still answers its discovery endpoint and the hr-portal client remains registered with the corrected, code flow configuration ready for Lab 05.
Section 11

Recommended Learning Links

Section 12 (Bonus)

Portfolio Publishing Guide: Evidence of a Flow You Drove and Broke

Lab 04 produces two portfolio assets most candidates lack: a from scratch, manual walkthrough of the flow every SDK hides, and a set of negative tests proving you understand why it is safe. The forty line application is a bonus artefact that reads as competence, provided you sanitise it.

12.1 Prepare sanitised artefacts

Redact and collect the evidence
cd ~/identity-bytes-architect-labs
mkdir -p lab-04-oidc-pkce/{docs,src,evidence,screenshots}

# Publish the demo app with the secret removed
sed 's/CLIENT_SECRET = ".*"/CLIENT_SECRET = "<set-your-own>"/' \
  ~/ib-sia-04/app.py > lab-04-oidc-pkce/src/app.py

# Confirm no secret survived. Expected: the placeholder line only.
grep CLIENT_SECRET lab-04-oidc-pkce/src/app.py

# Save a REDACTED token response as evidence of the exchange shape.
# Blank the actual token strings, keep the structure.
jq '.access_token="REDACTED" | .refresh_token="REDACTED" | .id_token="REDACTED"' \
  ~/ib-sia-04/tokens.json > lab-04-oidc-pkce/evidence/token-response.redacted.json

Do not publish verifier.txt, challenge.txt, state.txt, or the raw tokens.json; they are single use secrets, but publishing them normalises a bad habit.

SECURITY WARNINGThe client secret and any live token never enter Git. After publishing, rotate the hr-portal secret in the Credentials tab as a matter of routine.

12.2 Add Lab 04 to the repository and push

Create the README, commit, and push
cat > lab-04-oidc-pkce/README.md << 'EOF'
# Lab 04: OIDC Flows End to End with Authorization Code and PKCE

Part of my Identity Bytes Senior IAM Architect lab series (IB-SIA-04).
Remediates the Lab 02 HR portal, which used the prohibited password grant,
by re-platforming it onto the authorization code flow with PKCE.

## Problem this solves
Applications must never handle the user's password, and the flow that
removes it must survive a hostile network. The authorization code flow
with PKCE keeps credentials at the IdP and makes an intercepted code
useless without the client's secret verifier.

## What I built
- Reconfigured the confidential client: standard flow only, password
  grant disabled, PKCE (S256) required
- Drove the full flow by hand: generated the PKCE verifier and challenge
  with openssl, built the authorization URL, captured the code, and
  exchanged it on the back channel
- A minimal ~40 line Python application that completes the round trip
  the way an SDK does, including state checking
- Refresh token rotation with reuse detection

## What I broke on purpose
- Exchanged a valid code with a wrong PKCE verifier: rejected (this is
  the interception attack PKCE defeats)
- Replayed a used code: rejected (single use)
- Diverted the exchange to a different redirect_uri: rejected
- Reused a rotated refresh token: whole session revoked

## OIDC vs SAML
Having captured a JWT here and a SAML assertion in Lab 03, the README
includes a side by side of sub vs NameID, aud vs AudienceRestriction,
and where each protocol fits today.

## Skills demonstrated
OAuth 2.1 / OIDC flow internals, PKCE, public vs confidential clients,
state and nonce, refresh token rotation, and evidence based protocol
selection.

Full guide: docs/IB-SIA-04-oidc-pkce.html
Demo app: src/app.py   Redacted evidence: evidence/token-response.redacted.json
EOF

cp <PATH_TO>/IB-SIA-04-oidc-pkce.html lab-04-oidc-pkce/docs/
git add .
git commit -m "Lab 04: authorization code + PKCE, manual flow, demo app, refresh rotation, negative tests"
git push
# Track index: | 04 | OIDC authorization code + PKCE, refresh rotation | Complete |

12.3 Share it on LinkedIn

Attach a screenshot of the terminal moment where a captured code is refused because the verifier is wrong, the single clearest picture of why PKCE matters. GitHub link in the first comment. A draft in the Identity Bytes style:

Draft post:

I stole an authorization code from my own browser this weekend, and then watched it turn to dust in my hands.

Here is why that was the goal. In an earlier lab I took a shortcut that most tutorials take: I got tokens using the OAuth password grant, where the application collects the user's password and trades it for tokens. It is simple, it works in one command, and every current security standard tells you to delete it, because it puts the password back in the application's hands and bypasses the identity provider entirely.

So this week I did it properly, by hand, no SDK hiding the machinery. The application sends the user to the identity provider, the user logs in there and only there, and what comes back through the browser is not a token but a single use code. The application then exchanges that code on a back channel the browser never sees. The clever part is PKCE: before it starts, the application invents a secret, sends only the hash of it, and must later prove it holds the original to redeem the code.

That is what let me run the attack. I captured a valid code mid flow, exactly as a network eavesdropper would, and tried to exchange it. Rejected. Without the secret verifier, the code is inert. I replayed a used code. Rejected. I pointed the exchange at a different address. Rejected. Then I turned on refresh token rotation and reused an old refresh token, and the identity provider did the right thing: it revoked the entire session, turning a silent theft into a loud, recoverable alarm.

The lesson underneath all of it: good authentication design assumes the network is hostile and the browser is leaky, and makes the interesting secrets never travel there. The code can be stolen. It just cannot be used.

Lab four of thirty six in the senior IAM architecture series I am publishing. Guide, the small demo app, and redacted evidence are on my GitHub, link in the comments.

For those onboarding applications to SSO: how many in your estate still request tokens with a grant your own standard prohibits?

Same cadence: Tuesday to Thursday morning UK time, replies within two hours, hashtags at the end (#IAM #OAuth #OIDC #PKCE #CyberSecurity), and a comment linking the earlier labs.

12.4 Interview leverage from this lab

This lab evidences the token strategy and OIDC lines of the target role, and it hands you the strongest possible answer to "walk me through the authorization code flow", because you have driven every step from the command line and can explain not just the happy path but what each parameter defends against. When the interviewer asks why PKCE exists or how refresh rotation detects theft, you are describing experiments you ran, not paragraphs you read.