Lab Metadata
| Attribute | Value |
|---|---|
| Lab ID | IB-SIA-04 |
| Track | Identity Bytes Senior IAM Architect Track (36 lab curriculum) |
| Difficulty | Intermediate (requires IB-SIA-01 and IB-SIA-02; IB-SIA-03 recommended) |
| Core technologies | Keycloak 26.0 (from IB-SIA-02), OpenLDAP (from IB-SIA-01), curl, jq, openssl, Python 3 http.server, browser developer tools |
| Protocols and standards | OAuth 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 on | IB-SIA-02 (the northgate realm and hr-portal client are reconfigured for the code flow) |
| Feeds into | IB-SIA-05 (MFA lands inside this flow), IB-SIA-10 (OAuth 2.1 hardening: DPoP, PAR), IB-SIA-11 (token exchange) |
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.
Prerequisites
3.1 Prior labs required
| Lab | Why it is required |
|---|---|
IB-SIA-01 | OpenLDAP remains the password store behind the IdP's login page |
IB-SIA-02 | The 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
| Resource | Minimum | Recommended |
|---|---|---|
| Operating system | Ubuntu 22.04, macOS 13, or Windows 11 with WSL2 | Ubuntu 22.04 LTS |
| RAM | 6 GB | 8 GB |
| Disk | 12 GB free | 20 GB free |
| Network | Local port 9090 must be free for the demo application in Phase 4. | |
3.3 Required tools and versions
| Tool | Version | Purpose |
|---|---|---|
| Docker, curl, jq | As installed in Labs 01 and 02 | Container control, HTTP requests, JSON and JWT decoding |
| openssl | 1.1.1 or later | Generates the PKCE verifier and its SHA-256 challenge |
| Python 3 | 3.8 or later | Runs the minimal callback application in Phase 4 |
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+
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.
Skills Mapped to Production Solutions
| Skill Learned | Real World Enterprise Application |
|---|---|
| Driving the authorization code flow manually | Understanding 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 challenge | Securing mobile and single page application logins, the mandatory pattern for public clients across the entire modern application estate |
| Configuring public versus confidential clients correctly | Onboarding 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 tokens | Frontline troubleshooting of redirect_uri mismatches and code exchange failures, the two most common OIDC integration tickets |
| Using the state and nonce parameters | Defending 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 rotation | Designing long lived sessions safely, and detecting token theft through rotation breakage, a modern SOC signal |
| Contrasting OIDC and SAML flows from captured evidence | The build versus integrate and protocol selection decisions a senior architect defends in design review and interview |
Architecture Overview
Component breakdown
| Component | Purpose | Technology | Deployment | Ports | Key configuration |
|---|---|---|---|---|---|
| User agent | Front channel carrier: relays the authorization request and the returned code, never the tokens | Any browser | Host | n/a | Address bar and developer tools are your capture points |
| HR portal client | The confidential application that starts the flow, holds the PKCE verifier and client secret, and exchanges the code on the back channel | Keycloak OIDC client + a Python callback listener | Reconfigured hr-portal; listener on host | 9090 (listener) | Standard flow ON, Direct access grants OFF, PKCE code challenge method S256, redirect URI http://localhost:9090/callback |
| Keycloak authorization endpoint | Authenticates the user, stores the PKCE challenge against the code, issues the single use code | Keycloak /authorize | Existing container | 8081 | Binds code to challenge, state, and redirect URI |
| Keycloak token endpoint | Verifies the client, matches the PKCE verifier to the stored challenge, and issues tokens | Keycloak /token | Existing container | 8081 | Rejects code reuse, verifier mismatch, and redirect URI mismatch |
| OpenLDAP | The password store behind the IdP login page | osixia/openldap (Lab 01) | Existing | 389 | Unchanged |
Data flow
- 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
/authorizecarrying 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. - 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.
- 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.
- The client exchanges the code on the back channel: the HR portal calls
/tokendirectly, 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. - 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
| Control | In this lab |
|---|---|
| Credential confinement | The password is entered only on Keycloak's page; the application provably never receives it, unlike the Lab 02 password grant |
| Code interception resistance | PKCE 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 defence | The 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 tokens | Refresh token rotation issues a new refresh token on each use and invalidates the old, so a leaked refresh token is detectable and self limiting |
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
Bring the Lab 02 client into line with the corporate standard: standard flow only, direct access grants off, PKCE required.
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.
- Go to Clients → hr-portal → Settings.
- Under Capability config: Client authentication ON (still confidential), Standard flow ON, Direct access grants OFF, Implicit flow OFF, Service accounts roles OFF.
- Confirm Valid redirect URIs includes http://localhost:9090/callback and http://localhost:9090/*. Set Web origins to http://localhost:9090. Click Save.
- Open the Advanced tab, find Proof Key for Code Exchange Code Challenge Method, and set it to S256. Click Save.
{"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}. The old door is bricked up, which is the finding closed.Phase 2: Run the Flow by Hand, Part One: the Authorization Request
Step 2.1: Generate the PKCE verifier and challenge
Create the two linked cryptographic values at the heart of the flow, so you understand exactly what an SDK generates for you.
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"
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.
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
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.
- Paste the URL into a private browser window. Keycloak's login page appears.
- Sign in as asmith with the Lab 01
<USER_PASSWORD>. - The browser redirects to
http://localhost:9090/callback?code=...&state=...and shows a connection refused error. Copy the entire URL from the address bar.
A callback URL containing a long code value and a state that matches the one you generated.
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.Phase 3: Run the Flow by Hand, Part Two: the Token Exchange
Step 3.1: Exchange the code for tokens on the back channel
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
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.
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./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
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.
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.
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.
Phase 5: Refresh Token Rotation
Step 5.1: Enable rotation and observe a refresh token die on reuse
Turn on refresh token rotation and prove that reusing an old refresh token breaks the whole chain, the property that makes long sessions safe.
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.
- 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.
Use 1 succeeds and returns a new refresh token. Reusing the original fails with invalid_grant, and the replacement token is dead too.
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
| Concern | OIDC authorization code + PKCE (this lab) | SAML 2.0 (Lab 03) |
|---|---|---|
| Credential the app receives | A code, then tokens on the back channel | A signed assertion posted to the ACS |
| Token format | Compact JWT, base64url JSON | Verbose XML assertion |
| Interception defence | PKCE binds the code to the client | XML signature plus audience and time conditions |
| Identifier of the user | sub claim | <NameID> element |
| Who may consume | aud claim | <AudienceRestriction> |
| Best fit today | New build: web, SPA, mobile, API | Existing 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
| Symptom | Likely cause | Solution |
|---|---|---|
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 runs | Regenerate both together in Step 2.1 and use the same shell session |
unauthorized_client at /token | Standard flow disabled, or you sent grant_type=password | Confirm Standard flow ON and use grant_type=authorization_code |
| Authorization request shows "Invalid parameter: redirect_uri" | The redirect URI is not registered on the client | Add 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 endpoint | CLIENT_SECRET placeholder not replaced, or port 9090 in use | Paste the real secret; free port 9090 or change it consistently in the client and script |
| Refresh reuse test unexpectedly succeeds | Revoke refresh token not enabled, or max reuse above 0 | Recheck Realm settings, Sessions; set Revoke refresh token ON and max reuse 0 |
Security Analysis
What makes this implementation secure
- The application never receives the user's password: it is entered only at the IdP, and Stage 1 proves the password grant is now refused.
- PKCE S256 binds the authorization code to the client instance: test N1 proved a captured code cannot be redeemed without the verifier, defeating the interception attack the flow exists to stop.
- Multiple independent bindings guard the token exchange: client secret (N4), PKCE verifier (N1), redirect URI (N3), and single use codes (N2) must all hold.
- State is generated, echoed, and checked, defending against CSRF, and nonce ties the ID token to the originating request.
- Refresh token rotation with reuse detection turns a leaked refresh token into a detected, self limiting incident rather than a silent long lived compromise.
- Tokens are short lived, audience scoped, and RS256 signed, consistent with the token discipline established in Lab 02.
What is intentionally simplified for the lab
- Everything runs over HTTP on localhost; a real deployment mandates TLS, without which the back channel and code are exposed.
- The demo application hand rolls the flow for teaching; production uses vetted libraries or a proxy.
- The client secret is a static shared secret; stronger client authentication (private_key_jwt, mTLS) is not yet used.
- No Pushed Authorization Requests, no DPoP or other sender constraining, no MFA in the login step yet.
- Access token lifetime and session timeouts are left at defaults rather than tuned to a risk model.
Production hardening recommendations
| Recommendation | Why | Covered in |
|---|---|---|
| TLS everywhere, HSTS, secure and httpOnly cookies | Without transport security the code and back channel are interceptable regardless of PKCE | IB-SIA-17 to 19 (PKI phase) |
| Adopt Pushed Authorization Requests and consider JAR | Moves authorization parameters off the browser URL, reducing tampering and leakage surface | IB-SIA-10 (OAuth 2.1 hardening) |
| Sender constrain tokens with DPoP or mTLS | Binds tokens to a key so a stolen bearer token cannot be replayed by another party | IB-SIA-10 |
| Replace the static client secret with private_key_jwt or mTLS client auth | Removes the shared secret that ages and leaks; asymmetric client auth is the modern default | IB-SIA-10 |
| Enforce MFA and step up in the IdP login step | The flow delegates authentication to the IdP precisely so this control applies to every client at once | IB-SIA-05 and IB-SIA-08 |
| Tune access token lifetime, SSO idle and max, and refresh limits to a risk model | Defaults are a starting point; sensitive applications warrant shorter windows | IB-SIA-12 (session management controls) |
| Alert on refresh token reuse and PKCE failures in the SIEM | Both are strong signals of token theft or a misbehaving client | IB-SIA-35 (identity observability) |
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
~/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.Recommended Learning Links
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 7636: Proof Key for Code Exchange (PKCE)
- RFC 9700: Best Current Practice for OAuth 2.0 Security
- The OAuth 2.1 Authorization Framework (draft) (why implicit and password grants are removed)
- OpenID Connect Core 1.0 (authorization code flow, nonce, ID token)
- RFC 9126: Pushed Authorization Requests (PAR)
- Keycloak Server Administration Guide: OIDC clients and flows
- oauth.net PKCE overview (concise practitioner explanation)
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.
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.