01Lab Metadata
| Field | Value |
|---|---|
| Lab ID | IB-SIA-10 |
| Track | Identity Bytes, Senior IAM Architect Track |
| Phase | Phase 2, Token Engineering |
| Difficulty | Advanced |
| Estimated Time | 4.5 to 5.5 hours |
| Core Technologies | OAuth 2.1 (draft consolidation), DPoP (RFC 9449), PAR (RFC 9126), PKCE (RFC 7636), Python 3.11, jwcrypto 1.5, Flask 3.0, Keycloak 24.x, curl, jq |
| Builds On | Lab 04 (authorization code with PKCE, the flow this lab hardens), Lab 06 (HA cluster hosting the token and PAR endpoints), Lab 09 (JWT/JWS internals, needed to construct and read the DPoP proof) |
| Feeds Into | Lab 11 (token exchange, RFC 8693), Lab 12 (session management), Lab 16 (externalised authorization, where sender-constrained tokens are validated at the edge) |
02Lab Title and Description
OAuth 2.1 Hardening: DPoP and PAR
Every token Northgate Financial has issued so far is a bearer token. The defining and dangerous property of a bearer token is in its name: whoever bears it may use it. If an access token leaks, through a logging proxy, a browser extension, a cross-site scripting flaw or a shared machine, the attacker who captures it can replay it against the API and be treated as the legitimate user until the token expires. Northgate's own penetration test last quarter demonstrated exactly this, lifting an access token from a browser session and replaying it successfully from an entirely different machine.
OAuth 2.1 is the consolidation of a decade of OAuth 2.0 lessons into a single tighter specification: PKCE becomes mandatory for authorization code flows, the implicit and resource owner password grants are removed, and bearer tokens gain a proof-of-possession option. This lab implements two of the most important hardening mechanisms that OAuth 2.1 and its companion specifications bring. First, Demonstrating Proof of Possession (DPoP, RFC 9449) binds an access token to a cryptographic key held by the client, so that presenting the token also requires proving possession of that key. A stolen DPoP-bound token is inert without the private key, which never leaves the client. Second, Pushed Authorization Requests (PAR, RFC 9126) send the authorization request parameters directly from the client to Keycloak over a back channel, returning a short opaque reference, so that sensitive request parameters never travel through the browser address bar or referrer headers.
You will configure Keycloak to require both mechanisms for a hardened client, then write a Python client that pushes a PAR request, completes the authorization code flow, generates a DPoP proof for each call, and demonstrates that a token stolen without its key cannot be replayed. This is protocol-level engineering that a Senior IAM Architect is expected to specify and validate, not merely enable in a console.
Estimated completion time: 4.5 to 5.5 hours, including DPoP proof construction, PAR configuration, and replay testing.
03Prerequisites
Completed Prior Labs
| Lab | Why it is required |
|---|---|
| IB-SIA-04, Authorization code with PKCE | This lab hardens the exact authorization code flow built in Lab 04. PKCE, introduced there, is mandatory under OAuth 2.1 and remains in place alongside DPoP and PAR. |
| IB-SIA-06, Keycloak High Availability | The token endpoint, the PAR endpoint and the DPoP nonce handling are all served through the ib-lb load balancer. DPoP nonce state is held per node, which has consequences for an HA deployment that this lab surfaces. |
| IB-SIA-09, JWT, JWS and JWE Deep Dive | A DPoP proof is itself a signed JWT with a specific header and claim set. You need the JWS construction and verification understanding from Lab 09 to build and read the proof correctly. |
System Requirements
| Resource | Minimum |
|---|---|
| OS | Ubuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2 |
| RAM | 7 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this) |
| Disk | 3 GB free |
| CPU | 2 cores sufficient |
| Network | Access to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI |
Required Tools
| Tool | Exact Version |
|---|---|
| Python | 3.11.x |
| jwcrypto | 1.5.6 |
| requests | 2.32.3 |
| Flask | 3.0.3 (for the minimal resource server that checks DPoP binding) |
| curl | 8.x |
| jq | 1.7 |
Install and verify: Ubuntu/Debian
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip jq curl
mkdir -p ~/ib-labs/ib-oauth21
cd ~/ib-labs/ib-oauth21
python3.11 -m venv .venv
source .venv/bin/activate
pip install jwcrypto==1.5.6 requests==2.32.3 flask==3.0.3
# Verify
python3.11 --version # Expect: Python 3.11.x
python3.11 -c "import jwcrypto, requests, flask; print('libs ok')"
jq --version # Expect: jq-1.7 or later
Install and verify: macOS
brew install python@3.11 jq
mkdir -p ~/ib-labs/ib-oauth21 && cd ~/ib-labs/ib-oauth21
python3.11 -m venv .venv && source .venv/bin/activate
pip install jwcrypto==1.5.6 requests==2.32.3 flask==3.0.3
python3.11 -c "import jwcrypto, requests, flask; print('libs ok')"
jq --version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04
# Then follow the Ubuntu/Debian instructions above.
northgate realm to require DPoP and PAR. Create a fresh client rather than modifying hr-portal, so the flows exercised by prior labs continue to work. The steps below create hr-portal-hardened specifically for this purpose.
04Real World Problem Statement
A bearer token is a password that Northgate hands to an application and hopes never leaks. DPoP changes the model from "possession of the token is sufficient" to "possession of the token and proof of the matching private key is required", and PAR removes sensitive authorization parameters from the one channel Northgate controls least: the user's browser.
Risk
Token theft and replay is a proven, demonstrated risk at Northgate. A bearer token lifted from a browser session was successfully replayed from a different machine during the last penetration test. DPoP directly neutralises that replay, because the attacker cannot produce a valid proof without the client's private key.
Compliance
The Financial-grade API (FAPI 2.0) security profile, referenced by open banking regimes including the UK's, requires sender-constrained tokens and pushed authorization requests. Northgate's ambition to offer open banking style APIs makes DPoP and PAR a compliance prerequisite, not an optional enhancement.
Productivity
Adopting the standard mechanisms, rather than a bespoke token-binding scheme, means Northgate's APIs interoperate with any compliant client library. Engineers do not have to invent or maintain proprietary anti-replay logic.
Security Posture
PAR moves the authorization request off the front channel, so parameters such as scope and claims requests are never exposed in browser history, referrer headers or server access logs. Combined with mandatory PKCE, the front channel carries only an opaque reference.
Concrete scenario: Northgate's penetration testers captured jpatel's access token from browser developer tools on a shared workstation and replayed it from their own laptop against a protected API, gaining jpatel's access. In this lab you reproduce that theft against your own environment, first showing that a plain bearer token replays successfully, then enabling DPoP and showing that the same stolen token is rejected because the replay cannot present a valid proof bound to the original client's key.
05Skills Mapped to Production Solutions
| Skill Learned | Real-World Enterprise Application |
|---|---|
Constructing a DPoP proof JWT with the correct htm, htu, jti and ath claims and embedded JWK header | Building or integrating DPoP-capable clients for FAPI 2.0 and open banking APIs |
Configuring Keycloak to require sender-constrained tokens and validating the cnf/jkt confirmation claim | Rolling out proof-of-possession token binding across an enterprise API estate |
Implementing Pushed Authorization Requests and consuming the returned request_uri | Meeting the FAPI 2.0 requirement to keep authorization parameters off the front channel |
Handling the DPoP nonce challenge (use_dpop_nonce) and retry loop | Correctly implementing the server-driven anti-replay nonce mechanism that production authorization servers enforce |
| Demonstrating and then defeating a token replay attack | Security validation and threat modelling of token handling, directly feeding Lab 32's threat modelling work |
| Reasoning about DPoP nonce state in a multi-node HA cluster | Diagnosing subtle proof-of-possession failures that only appear under load balancing |
06Architecture Overview
Component Breakdown
| Component | Purpose | Technology | Deployment | Ports | Key Configuration |
|---|---|---|---|---|---|
| Hardened client | Pushes the PAR request, completes the code flow, holds the DPoP key and signs a proof per request | Python 3.11, jwcrypto (ES256 keypair) | Runs locally in the lab virtual environment | N/A | DPoP private key generated once, kept in memory or a local file for the lab only |
| Keycloak | Enforces PAR, issues DPoP-bound tokens, binds the key thumbprint into the token's cnf/jkt claim, drives the nonce challenge | Keycloak 24.x | Existing HA cluster from Lab 06, via ib-lb | 8443 | Client hr-portal-hardened with PAR required and DPoP bound; realm DPoP feature enabled |
| Resource server | Validates both the access token signature and the DPoP proof, confirming the proof key matches the token's bound thumbprint | Python 3.11, Flask 3.0, jwcrypto | Docker container ib-dpop-api on ib-lab-net | 8095 | Fetches realm JWKS; computes and compares the JWK thumbprint against cnf/jkt |
Data Flow
- The client pushes the authorization request to the PAR endpoint over the back channel and receives a short-lived opaque
request_uri.
Why: the browser is then redirected using only that opaque reference, so scope, claims and PKCE parameters never appear in the address bar, browser history or referrer headers. - The client exchanges the authorization code at the token endpoint, attaching a DPoP proof signed with its private key. Keycloak computes the SHA-256 thumbprint of the proof's embedded public key and binds it into the issued token as the
cnf/jktconfirmation claim.
Why: this is the moment the token stops being a bearer token; from here on the token is only usable by whoever can sign a proof with the matching private key. - On the first token request Keycloak may respond with
use_dpop_nonceand aDPoP-Nonceheader. The client repeats the request with the nonce included in the proof'snonceclaim.
Why: the server-supplied nonce prevents an attacker from pre-computing or replaying proofs, since a valid proof must contain a fresh nonce the server issued. - The client calls the resource server, sending the access token in a
DPoPauthorization scheme header plus a fresh proof whoseathclaim hashes that exact token. The resource server verifies the token, verifies the proof, and confirms the proof's key thumbprint equals the token'scnf/jkt.
Why: binding the proof to the specific token viaath, and binding the token to the key viajkt, together make a stolen token useless without the private key.
Security Considerations
| Concern | Lab Approach |
|---|---|
| Proof freshness | Each proof carries a unique jti and an iat; the resource server rejects proofs outside a short time window and, in production, tracks recently seen jti values to prevent proof replay. |
| Token to key binding | The resource server does not trust the token alone; it recomputes the JWK thumbprint from the presented proof and compares it to the token's cnf/jkt, rejecting any mismatch. |
| Front-channel exposure | PAR ensures only an opaque request_uri reaches the browser; PKCE remains in force so an intercepted code cannot be exchanged without the verifier. |
| HA nonce state | DPoP nonce state is per node; Section 7 documents how the client's retry loop handles receiving a nonce from one node and being routed to another. |
07Step by Step Implementation
Phase 1: Establish the Baseline Replay (Bearer Token)
Step 1.1: Create the hardened client, initially as a plain bearer client
Create hr-portal-hardened via kcadm.sh
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create clients \
-r northgate \
-s clientId=hr-portal-hardened \
-s enabled=true \
-s protocol=openid-connect \
-s publicClient=true \
-s standardFlowEnabled=true \
-s 'redirectUris=["http://localhost:8081/callback"]' \
-s 'attributes."pkce.code.challenge.method"=S256'
# Capture the client UUID for later steps
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
clients?clientId=hr-portal-hardened -r northgate --fields id | jq -r '.[0].id'
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get clients?clientId=hr-portal-hardened -r northgate | jq '.[0].attributes["pkce.code.challenge.method"]' should print "S256". If the array is empty, the create step failed; check the realm name is exactly northgate.
Step 1.2: Build the minimal resource server
resource_server.py, bearer mode first
# resource_server.py
# A deliberately small resource server. In BEARER_ONLY mode it accepts any
# validly signed, unexpired access token, which is exactly the replayable
# behaviour this lab sets out to fix. In Phase 3 you flip DPOP_REQUIRED to
# True and it additionally enforces sender-constrained binding.
import os
import hashlib
import base64
import json
import time
from flask import Flask, request, jsonify
import requests
from jwcrypto import jwt, jwk, jws
app = Flask(__name__)
ISSUER = "https://ib-lb:8443/realms/northgate"
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"
DPOP_REQUIRED = os.environ.get("DPOP_REQUIRED", "false").lower() == "true"
# Lab only: ib-lb presents a self-signed certificate from Lab 06.
_jwks = jwk.JWKSet.from_json(requests.get(JWKS_URL, verify=False).text)
def _verify_access_token(token):
verified = jwt.JWT(jwt=token, key=_jwks, algs=["RS256"])
return json.loads(verified.claims)
def _b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _verify_dpop_proof(proof, method, url, access_token, expected_jkt):
# Parse without verification first to read the embedded JWK from the header.
header_b64 = proof.split(".")[0]
header_b64 += "=" * (-len(header_b64) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64))
if header.get("typ") != "dpop+jwt":
raise ValueError("proof typ is not dpop+jwt")
proof_key = jwk.JWK(**header["jwk"])
# Verify the proof signature with the key it carries.
verified = jwt.JWT(jwt=proof, key=proof_key, algs=["ES256"])
claims = json.loads(verified.claims)
# The proof's key thumbprint must equal the token's bound thumbprint.
computed_jkt = _b64url(hashlib.sha256(proof_key.thumbprint().encode()).digest()) \
if False else proof_key.thumbprint(hashalg=hashlib.sha256)
if computed_jkt != expected_jkt:
raise ValueError("proof key thumbprint does not match token cnf/jkt")
if claims.get("htm") != method:
raise ValueError("htm mismatch")
if claims.get("htu") != url:
raise ValueError("htu mismatch")
# ath binds this proof to this exact access token.
expected_ath = _b64url(hashlib.sha256(access_token.encode()).digest())
if claims.get("ath") != expected_ath:
raise ValueError("ath does not match presented access token")
if abs(time.time() - claims.get("iat", 0)) > 60:
raise ValueError("proof iat outside acceptable window")
return claims
@app.get("/api/payslip")
def payslip():
auth = request.headers.get("Authorization", "")
scheme, _, token = auth.partition(" ")
try:
if DPOP_REQUIRED:
if scheme != "DPoP":
return jsonify({"error": "DPoP scheme required"}), 401
claims = _verify_access_token(token)
jkt = claims.get("cnf", {}).get("jkt")
if not jkt:
return jsonify({"error": "token is not sender-constrained"}), 401
proof = request.headers.get("DPoP", "")
_verify_dpop_proof(
proof,
method="GET",
url="https://localhost:8095/api/payslip",
access_token=token,
expected_jkt=jkt,
)
else:
if scheme.lower() != "bearer":
return jsonify({"error": "Bearer scheme required"}), 401
claims = _verify_access_token(token)
except Exception as e:
return jsonify({"error": str(e)}), 401
return jsonify({
"user": claims.get("preferred_username"),
"payslip": "NET PAY 3,214.55 GBP (demo)",
"sender_constrained": DPOP_REQUIRED,
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8095, ssl_context="adhoc")
Run the resource server in bearer mode
# In its own terminal, from the lab virtual environment
export DPOP_REQUIRED=false
python3.11 resource_server.py
ib-lb:8443 from your host, and that the ISSUER value matches your environment.
Step 1.3: Demonstrate the replay works with a plain bearer token
Obtain a token and replay it from a "different machine"
# Obtain a token (direct grant used purely for baseline demonstration).
TOKEN=$(curl -sk -X POST \
"https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
-d "client_id=hr-portal-hardened" \
-d "grant_type=password" \
-d "username=jpatel" \
-d "password=REPLACE_WITH_JPATEL_PASSWORD" \
-d "scope=openid profile" | jq -r '.access_token')
# "Legitimate" call
curl -sk -H "Authorization: Bearer $TOKEN" \
https://localhost:8095/api/payslip | jq .
# The SAME token string, as if copied to an attacker's machine, replays fine.
echo "$TOKEN" > /tmp/stolen_token.txt
curl -sk -H "Authorization: Bearer $(cat /tmp/stolen_token.txt)" \
https://localhost:8095/api/payslip | jq .
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update clients/CLIENT_UUID -r northgate -s directAccessGrantsEnabled=true. It is disabled again in Cleanup.
Phase 2: Enable PAR and DPoP on Keycloak
Step 2.1: Require Pushed Authorization Requests for the client
Set the PAR requirement
# Replace CLIENT_UUID with the value captured in Step 1.1.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
clients/CLIENT_UUID -r northgate \
-s 'attributes."require.pushed.authorization.requests"=true'
curl -sk https://localhost:8443/realms/northgate/.well-known/openid-configuration | jq '.pushed_authorization_request_endpoint' should return a URL ending in /protocol/openid-connect/ext/par/request. If it returns null, PAR is not enabled at the realm or server level; confirm your Keycloak build includes the PAR feature, which is enabled by default in Keycloak 24.x.
Step 2.2: Require DPoP-bound access tokens for the client
Confirm the dpop feature is enabled, then require it on the client
# DPoP is a preview feature in some Keycloak 24.x builds. Confirm it is on.
# If your cluster was started without it, add --features=dpop to the start
# command and rebuild, following the same rolling restart pattern as Lab 08.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
serverinfo -r master | jq '.features[] | select(.name=="dpop")'
# Require DPoP binding on the client.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
clients/CLIENT_UUID -r northgate \
-s 'attributes."dpop.bound.access.tokens"=true'
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get clients/CLIENT_UUID -r northgate | jq '.attributes["dpop.bound.access.tokens"]' should print "true". If the dpop feature is absent from serverinfo, tokens will not carry a cnf/jkt claim no matter what the client sends; enable the feature and rebuild before continuing.
Phase 3: Build the DPoP Client and Defeat the Replay
Step 3.1: Generate the client DPoP key and a proof builder
dpop.py, the proof builder
# dpop.py
# Purpose: hold the client DPoP keypair and build a DPoP proof JWT for a
# given HTTP method and URL, optionally binding to an access token (ath)
# and a server nonce.
import time
import uuid
import hashlib
import base64
import json
from jwcrypto import jwk, jwt
# Generate an EC P-256 key for ES256 proofs. In a real client this is
# created once and kept for the client's lifetime (or per session).
CLIENT_KEY = jwk.JWK.generate(kty="EC", crv="P-256")
def _b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def public_jwk():
# Public portion only, as embedded in the proof header.
return json.loads(CLIENT_KEY.export_public())
def key_thumbprint():
return CLIENT_KEY.thumbprint(hashalg=hashlib.sha256)
def build_proof(method, url, access_token=None, nonce=None):
header = {
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": public_jwk(),
}
claims = {
"htm": method,
"htu": url,
"jti": str(uuid.uuid4()),
"iat": int(time.time()),
}
if access_token is not None:
claims["ath"] = _b64url(hashlib.sha256(access_token.encode()).digest())
if nonce is not None:
claims["nonce"] = nonce
proof = jwt.JWT(header=header, claims=claims)
proof.make_signed_token(CLIENT_KEY)
return proof.serialize()
python3.11 -c "import dpop; print(dpop.key_thumbprint()); print(dpop.build_proof('GET','https://example/api')[:40], '...')". You should see a base64url thumbprint string and the first characters of a signed proof. If jwcrypto raises an error about the EC curve, confirm you installed jwcrypto 1.5.6 as specified.
Step 3.2: Run the full hardened flow: PAR, code exchange with DPoP, and the nonce retry
hardened_client.py
# hardened_client.py
# Purpose: run the OAuth 2.1 hardened flow end to end. For lab clarity this
# uses the authorization code flow with a manual browser step for the login,
# then focuses on the machine-to-machine hardening: PAR, DPoP and the nonce
# retry loop on the token endpoint.
import base64
import hashlib
import os
import secrets
import sys
import requests
import dpop
BASE = "https://localhost:8443/realms/northgate/protocol/openid-connect"
PAR_URL = f"{BASE}/ext/par/request"
AUTH_URL = f"{BASE}/auth"
TOKEN_URL = f"{BASE}/token"
CLIENT_ID = "hr-portal-hardened"
REDIRECT_URI = "http://localhost:8081/callback"
requests.packages.urllib3.disable_warnings() # lab: self-signed ib-lb cert
def pkce_pair():
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
return verifier, challenge
def push_par(challenge):
resp = requests.post(PAR_URL, data={
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": "openid profile",
"code_challenge": challenge,
"code_challenge_method": "S256",
}, verify=False)
resp.raise_for_status()
return resp.json()["request_uri"]
def token_with_dpop(code, verifier):
"""Exchanges the code, attaching a DPoP proof. Handles the server nonce
challenge: if Keycloak responds use_dpop_nonce, retry once with the
supplied nonce embedded in a fresh proof."""
def attempt(nonce=None):
proof = dpop.build_proof("POST", TOKEN_URL, nonce=nonce)
return requests.post(TOKEN_URL, headers={"DPoP": proof}, data={
"client_id": CLIENT_ID,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier,
}, verify=False)
resp = attempt()
if resp.status_code == 400 and resp.json().get("error") == "use_dpop_nonce":
nonce = resp.headers.get("DPoP-Nonce")
resp = attempt(nonce=nonce)
resp.raise_for_status()
return resp.json()
if __name__ == "__main__":
verifier, challenge = pkce_pair()
request_uri = push_par(challenge)
print("PAR pushed. Opaque request_uri returned:")
print(" ", request_uri)
print("\nOpen this URL in a browser, log in as jpatel, and copy the")
print("'code' parameter from the redirect back here:")
print(f" {AUTH_URL}?client_id={CLIENT_ID}&request_uri={request_uri}")
code = input("\nPaste the authorization code: ").strip()
tokens = token_with_dpop(code, verifier)
with open("dpop_access_token.txt", "w") as f:
f.write(tokens["access_token"])
print("\nToken type returned:", tokens.get("token_type"))
print("Saved sender-constrained access token to dpop_access_token.txt")
Run the hardened flow
python3.11 hardened_client.py
request_uri of the form urn:ietf:params:oauth:request_uri:..., and after you complete the browser login and paste the code, reports a token type of DPoP (not Bearer). Decode the saved token as you did in Lab 09 and confirm it contains a cnf claim with a jkt value. If the token type is still Bearer, the dpop.bound.access.tokens attribute did not take effect; re-check Step 2.2.
use_dpop_nonce exchange is not an error in the normal sense; it is the server asking the client to include a fresh, server-issued nonce in the proof. A correct client treats the first response as a challenge and retries once with the nonce. Getting this loop wrong is the single most common DPoP client bug.
Step 3.3: Switch the resource server to DPoP mode and prove the replay now fails
Restart the resource server in DPoP mode and test both callers
# In the resource server terminal, stop it (Ctrl+C) and restart enforcing DPoP
export DPOP_REQUIRED=true
python3.11 resource_server.py
# call_api.py: legitimate holder builds a fresh proof bound to the token
python3.11 - <<'PYEOF'
import requests, dpop
requests.packages.urllib3.disable_warnings()
API = "https://localhost:8095/api/payslip"
token = open("dpop_access_token.txt").read().strip()
proof = dpop.build_proof("GET", API, access_token=token)
r = requests.get(API, headers={
"Authorization": f"DPoP {token}",
"DPoP": proof,
}, verify=False)
print("Legitimate DPoP call:", r.status_code, r.json())
PYEOF
# Attacker replay: has the token string but NOT dpop.CLIENT_KEY.
# Simulate by trying a bearer replay and a forged proof from a different key.
python3.11 - <<'PYEOF'
import requests, json
from jwcrypto import jwk, jwt
import base64, hashlib, time, uuid
requests.packages.urllib3.disable_warnings()
API = "https://localhost:8095/api/payslip"
token = open("dpop_access_token.txt").read().strip()
# Attempt 1: plain bearer replay of the stolen token.
r1 = requests.get(API, headers={"Authorization": f"Bearer {token}"}, verify=False)
print("Stolen token, bearer replay:", r1.status_code, r1.json())
# Attempt 2: attacker forges a proof with THEIR OWN key (they lack the real one).
attacker_key = jwk.JWK.generate(kty="EC", crv="P-256")
def b64url(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
header = {"typ":"dpop+jwt","alg":"ES256","jwk":json.loads(attacker_key.export_public())}
claims = {"htm":"GET","htu":API,"jti":str(uuid.uuid4()),"iat":int(time.time()),
"ath": b64url(hashlib.sha256(token.encode()).digest())}
p = jwt.JWT(header=header, claims=claims); p.make_signed_token(attacker_key)
r2 = requests.get(API, headers={"Authorization": f"DPoP {token}", "DPoP": p.serialize()}, verify=False)
print("Stolen token, forged proof:", r2.status_code, r2.json())
PYEOF
cnf/jkt. This is the whole lab in three responses: the token string is worthless without the private key that never left the legitimate client.
ath from the stolen token; that was never the protection. The protection is that Keycloak bound the token to the original key's thumbprint at issuance, and the resource server checks the presented proof key against that bound thumbprint. Possession of the token does not grant possession of the key.
08Testing and Validation
End-to-End Test Scenarios
| Scenario | Steps | Expected Result |
|---|---|---|
| PAR front-channel hygiene | Inspect the authorization URL produced in Step 3.2 | URL contains only client_id and an opaque request_uri, no scope or PKCE parameters |
| DPoP token issuance | Decode dpop_access_token.txt | Token carries a cnf.jkt claim; token_type is DPoP |
| Legitimate sender-constrained call | Run the legitimate call in Step 3.3 | HTTP 200 with payslip |
| Nonce retry loop | Observe the token exchange in Step 3.2 | If Keycloak challenges with use_dpop_nonce, the client retries once and succeeds |
Negative Tests
| Test | Expected Result |
|---|---|
| Replay the stolen token with the Bearer scheme (Step 3.3 Attempt 1) | HTTP 401, DPoP scheme required |
| Present the stolen token with a proof signed by a different key (Step 3.3 Attempt 2) | HTTP 401, thumbprint mismatch |
Present a valid proof but change htu to a different URL | HTTP 401, htu mismatch, proving the proof is bound to the specific endpoint |
| Reuse a proof after modifying the system clock forward by two minutes | HTTP 401, iat outside the acceptable window |
Send an /auth request directly with scope parameters instead of a pushed request_uri | Keycloak rejects it because PAR is required for this client |
Common Failure Modes
| Symptom | Likely Cause | Resolution |
|---|---|---|
Token is issued but has no cnf/jkt claim | The dpop feature is not enabled on the server, or dpop.bound.access.tokens is not set on the client | Re-verify both parts of Step 2.2 |
Every token call returns use_dpop_nonce and never succeeds | The client is not echoing the returned nonce into the retried proof | Confirm the nonce from the DPoP-Nonce header is passed into build_proof |
| Intermittent nonce failures under repeated calls | DPoP nonce state is per node; ib-lb routed a retry to a different node than issued the nonce | Ensure the client always retries on use_dpop_nonce rather than assuming one nonce is durable across the cluster |
| Resource server rejects a legitimate proof with htu mismatch | The htu in the proof does not exactly match the URL the server compares against, including scheme and port | Align the htu string in the client with the exact URL string in resource_server.py |
09Security Analysis
What Makes This Implementation Secure
- Access tokens are sender-constrained: a stolen token is inert without the client private key, which is generated on the client and never transmitted.
- The resource server independently recomputes the presented proof key's thumbprint and compares it to the token's bound
cnf/jkt, so it never trusts the proof key on the client's say-so. - Each proof is bound to a specific HTTP method, URL and access token (
htm,htu,ath), so a captured proof cannot be redirected to a different endpoint or paired with a different token. - PAR removes scope, claims and PKCE parameters from the front channel, so they never appear in browser history, referrer headers or intermediary logs.
- PKCE remains mandatory alongside DPoP and PAR, so an intercepted authorization code cannot be exchanged without the code verifier.
What Is Intentionally Simplified for the Lab
- The resource server checks the proof
iatwindow but does not persist seenjtivalues; a production server tracks recentjtivalues (typically in Redis) to prevent proof replay within the time window. - TLS certificate verification against
ib-lband the local resource server is disabled because both present self-signed certificates in the lab. - The DPoP nonce handling is demonstrated but the lab does not stress-test nonce state across sustained load balancing; production testing should include node failover.
- The client DPoP key is stored in memory or a plain file for the lab; a production client protects it in secure storage appropriate to the platform.
Production Hardening Recommendations
| Area | Recommendation |
|---|---|
| Proof replay window | Persist recently seen proof jti values with a TTL matching the accepted iat window, and reject any repeat within it |
| Nonce enforcement | Require server-issued nonces on both the token and resource endpoints, and ensure clients handle the challenge and retry across an HA cluster |
| Key storage | Store the client DPoP private key in platform secure storage or an HSM, never in a world-readable file |
| Algorithm allowlist | Restrict accepted proof algorithms to a vetted set (for example ES256, ES384) and reject symmetric or none algorithms outright |
| Clock skew | Apply a small, bounded tolerance on iat to accommodate clock differences without widening the replay window unnecessarily |
10Cleanup
Stop the lab services and remove local artefacts
cd ~/ib-labs/ib-oauth21
# Stop the resource server terminal with Ctrl+C first, then:
rm -f dpop_access_token.txt /tmp/stolen_token.txt
deactivate 2>/dev/null || true
Revert the direct grant used for the baseline, and optionally remove the hardened client
# Disable the direct grant enabled for the Step 1.3 baseline demonstration.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
clients/CLIENT_UUID -r northgate -s directAccessGrantsEnabled=false
# Optional full teardown: remove the hardened client entirely. Keep it if you
# intend to reuse sender-constrained tokens in Lab 11 or Lab 16.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh delete \
clients/CLIENT_UUID -r northgate
ls ~/ib-labs/ib-oauth21. The Keycloak HA cluster, OpenLDAP and PostgreSQL are otherwise unchanged and ready for Lab 11. If you kept hr-portal-hardened, confirm its direct grant is disabled again.
11Recommended Learning Links
- RFC 9449, OAuth 2.0 Demonstrating Proof of Possession (DPoP), IETF
- RFC 9126, OAuth 2.0 Pushed Authorization Requests (PAR), IETF
- RFC 7636, Proof Key for Code Exchange (PKCE), IETF
- OAuth 2.1 Authorization Framework, draft, IETF OAuth Working Group
- FAPI 2.0 Security Profile, OpenID Foundation
- Keycloak Server Administration Guide, DPoP and Pushed Authorization Requests sections, Keycloak documentation
- RFC 8725, JSON Web Token Best Current Practices, IETF
12Portfolio Publishing Guide
Sanitise Before Publishing
This lab handles real tokens, a client private key and user passwords. Confirm none reach your repository.
Sanitisation checklist commands
grep -R "REPLACE_WITH" . --include="*.py" --include="*.md" || echo "Clean"
cat >> .gitignore <<'EOF'
*_token.txt
dpop_access_token.txt
*.pem
.venv/
EOF
git status
README for the Repository
README.md skeleton
# IB-SIA-10: OAuth 2.1 Hardening, DPoP and PAR
A hardened OAuth 2.1 client and resource server demonstrating
sender-constrained tokens (DPoP, RFC 9449) and pushed authorization
requests (PAR, RFC 9126) against Keycloak, including a token replay attack
that succeeds with bearer tokens and fails with DPoP.
## Stack
Python 3.11, jwcrypto 1.5, Flask 3.0, Keycloak 24.x
## What it demonstrates
- Baseline: a stolen bearer token replays successfully
- PAR: authorization parameters pushed off the front channel
- DPoP: token bound to a client key via cnf/jkt
- The same stolen token is rejected once DPoP is enforced
- Correct handling of the use_dpop_nonce challenge
## Part of the Identity Bytes Senior IAM Architect Track
Lab 10 of 36. See identity-bytes.com for the full curriculum.
Git Commands
Commit and push
git add dpop.py hardened_client.py resource_server.py README.md .gitignore
git commit -m "IB-SIA-10: OAuth 2.1 hardening with DPoP sender-constrained tokens and PAR"
git push origin main
Track Index Line
Add the following line to your master portfolio index:
IB-SIA-10 | OAuth 2.1 Hardening, DPoP and PAR | Advanced | Sender-constrained tokens, proof of possession, pushed authorization requests
LinkedIn Draft
A bearer token is a house key with no lock that recognises you. Whoever picks it up can walk in.
Our last penetration test made that concrete: a tester lifted an access token from a browser session on a shared machine and replayed it from their own laptop. It worked, because that is exactly what a bearer token is designed to do. Possession is the whole authorization.
This week I implemented the OAuth 2.1 answer to that. DPoP (RFC 9449) binds every token to a cryptographic key the client holds and never transmits, so the token is inert to anyone who cannot sign a fresh proof with that key. I also added Pushed Authorization Requests (RFC 9126), which move the sensitive parts of the authorization request off the browser entirely and onto a back channel, leaving only an opaque reference in the address bar.
The demonstration I care about is the last one. I stole the full token string, correctly hashed it into a forged proof, and presented both. Still rejected, because the one thing that cannot be copied is the private key that never left the legitimate client. The hardest part to get right was not the cryptography, it was the DPoP nonce challenge: the server-driven retry loop that most first implementations handle incorrectly.
How many of your APIs would survive a tester copying a live token to a second machine?