IB-SIA-10 Advanced Est. 4.5 to 5.5 hours
Identity Bytes // Senior IAM Architect Track

OAuth 2.1 Hardening: DPoP and PAR

Bind tokens to a client key so a stolen token is useless (DPoP, RFC 9449), and push authorization request parameters off the browser and straight to Keycloak (PAR, RFC 9126), the two protocol upgrades that move Northgate from bearer tokens to sender-constrained ones.

01Lab Metadata

FieldValue
Lab IDIB-SIA-10
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 2, Token Engineering
DifficultyAdvanced
Estimated Time4.5 to 5.5 hours
Core TechnologiesOAuth 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 OnLab 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 IntoLab 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

LabWhy it is required
IB-SIA-04, Authorization code with PKCEThis 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 AvailabilityThe 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 DiveA 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

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM7 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this)
Disk3 GB free
CPU2 cores sufficient
NetworkAccess to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI

Required Tools

ToolExact Version
Python3.11.x
jwcrypto1.5.6
requests2.32.3
Flask3.0.3 (for the minimal resource server that checks DPoP binding)
curl8.x
jq1.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.
SECURITY WARNING This lab reconfigures a client in the shared 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 LearnedReal-World Enterprise Application
Constructing a DPoP proof JWT with the correct htm, htu, jti and ath claims and embedded JWK headerBuilding 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 claimRolling out proof-of-possession token binding across an enterprise API estate
Implementing Pushed Authorization Requests and consuming the returned request_uriMeeting the FAPI 2.0 requirement to keep authorization parameters off the front channel
Handling the DPoP nonce challenge (use_dpop_nonce) and retry loopCorrectly implementing the server-driven anti-replay nonce mechanism that production authorization servers enforce
Demonstrating and then defeating a token replay attackSecurity 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 clusterDiagnosing subtle proof-of-possession failures that only appear under load balancing

06Architecture Overview

HARDENED CLIENT Python, jwcrypto holds DPoP private key key never leaves client signs a proof per request KEYCLOAK (via ib-lb) /par pushed auth endpoint /auth authorization endpoint /token issues DPoP-bound token binds cnf/jkt = thumbprint issues DPoP-Nonce hr-portal-hardened client RESOURCE SERVER ib-dpop-api (Flask) verifies token signature verifies DPoP proof checks jkt == proof key ATTACKER steals the token string but NOT the private key DPoP PROOF JWT (fresh per request) header: typ=dpop+jwt, alg=ES256, jwk={client public key} claims: htm (method), htu (URL), jti (unique id), iat (time) ath = hash of the access token (binds proof to this exact token) Attacker cannot forge this: signing needs the private key they do not have Keycloak binds cnf/jkt = SHA-256 thumbprint of the jwk at token issuance 1 PAR push 2 token + DPoP proof 3 API call + proof steals token Docker network: ib-lab-net

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
Hardened clientPushes the PAR request, completes the code flow, holds the DPoP key and signs a proof per requestPython 3.11, jwcrypto (ES256 keypair)Runs locally in the lab virtual environmentN/ADPoP private key generated once, kept in memory or a local file for the lab only
KeycloakEnforces PAR, issues DPoP-bound tokens, binds the key thumbprint into the token's cnf/jkt claim, drives the nonce challengeKeycloak 24.xExisting HA cluster from Lab 06, via ib-lb8443Client hr-portal-hardened with PAR required and DPoP bound; realm DPoP feature enabled
Resource serverValidates both the access token signature and the DPoP proof, confirming the proof key matches the token's bound thumbprintPython 3.11, Flask 3.0, jwcryptoDocker container ib-dpop-api on ib-lab-net8095Fetches realm JWKS; computes and compares the JWK thumbprint against cnf/jkt

Data Flow

  1. 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.
  2. 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/jkt confirmation 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.
  3. On the first token request Keycloak may respond with use_dpop_nonce and a DPoP-Nonce header. The client repeats the request with the nonce included in the proof's nonce claim.
    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.
  4. The client calls the resource server, sending the access token in a DPoP authorization scheme header plus a fresh proof whose ath claim hashes that exact token. The resource server verifies the token, verifies the proof, and confirms the proof's key thumbprint equals the token's cnf/jkt.
    Why: binding the proof to the specific token via ath, and binding the token to the key via jkt, together make a stolen token useless without the private key.

Security Considerations

ConcernLab Approach
Proof freshnessEach 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 bindingThe 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 exposurePAR 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 stateDPoP 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

Purpose: create a dedicated client so prior labs are unaffected Context: DPoP and PAR are switched on later so the baseline replay can be shown first
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'
VERIFICATION Confirm the client exists and PKCE is enforced: 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

Purpose: a protected API that first accepts bearer tokens, then enforces DPoP in Phase 3 Context: the same service is upgraded to check DPoP binding in Step 3.3
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
VERIFICATION The server should start and report it is listening on port 8095 with an adhoc TLS context. Leave it running in this terminal. If it fails with a JWKS fetch error, confirm the Keycloak cluster is up and reachable at 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

Purpose: prove the problem exists before fixing it Context: this is the attack DPoP will neutralise in Phase 3
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 .
SECURITY WARNING Both calls succeed and return the payslip. This is the entire problem: the resource server cannot tell the legitimate holder from anyone who copied the token string. Nothing about the token ties it to the client that obtained it. Enabling the direct grant here is for the baseline demonstration only; disable it again at cleanup.
INFO To use the direct grant for this baseline step, temporarily enable it: 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

Purpose: force authorization parameters off the front channel Context: after this, a plain /auth request without a pushed request_uri is rejected
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'
VERIFICATION Confirm the discovery document advertises the PAR endpoint: 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

Purpose: make Keycloak bind the client key thumbprint into every token it issues to this client Context: the DPoP feature must be enabled at server start; verify before relying on it
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'
VERIFICATION The serverinfo query should show the dpop feature with an enabled status. Then confirm the client attribute: 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.
PRODUCTION CONSIDERATION Enabling a preview feature in production requires the same change-control rigour as any other cluster-wide change. Confirm the feature's stability status for your exact Keycloak version, because preview features can change behaviour between releases in ways that a Senior IAM Architect is expected to track rather than assume.

Phase 3: Build the DPoP Client and Defeat the Replay

Step 3.1: Generate the client DPoP key and a proof builder

Purpose: create the ES256 key that binds tokens to this client, and a helper that signs a proof per request Context: the public key is embedded in every proof header; the private key never leaves the client
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()
VERIFICATION Run 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

Purpose: obtain a sender-constrained token through the complete hardened flow Context: demonstrates PAR push, DPoP proof on the token call, and correct handling of use_dpop_nonce
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
VERIFICATION The script prints an opaque 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.
INFO The 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

Purpose: demonstrate the stolen token is now inert without the private key Context: this is the payoff, the exact attack from Step 1.3 no longer works
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
VERIFICATION The legitimate DPoP call returns 200 with the payslip. Both attacker attempts return 401: the bearer replay fails because the DPoP scheme is required, and the forged proof fails because the attacker's key thumbprint does not match the token's 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.
SECURITY WARNING Notice the attacker in Attempt 2 correctly computed 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.
What just happened? You moved Northgate from bearer tokens to sender-constrained ones. First you proved the danger: a copied token string replayed cleanly from anywhere. Then you pushed the authorization request off the browser entirely with PAR, so the front channel carried only an opaque reference, and you had Keycloak bind each token to the client's cryptographic key with DPoP. The demonstration that lands hardest is the last one: an attacker who steals the whole token string, and even correctly hashes it into a forged proof, is still rejected, because the one thing they cannot copy is the private key that never left the legitimate client. You also met the DPoP nonce challenge, the server-driven retry that trips up most first implementations.

08Testing and Validation

End-to-End Test Scenarios

ScenarioStepsExpected Result
PAR front-channel hygieneInspect the authorization URL produced in Step 3.2URL contains only client_id and an opaque request_uri, no scope or PKCE parameters
DPoP token issuanceDecode dpop_access_token.txtToken carries a cnf.jkt claim; token_type is DPoP
Legitimate sender-constrained callRun the legitimate call in Step 3.3HTTP 200 with payslip
Nonce retry loopObserve the token exchange in Step 3.2If Keycloak challenges with use_dpop_nonce, the client retries once and succeeds

Negative Tests

TestExpected 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 URLHTTP 401, htu mismatch, proving the proof is bound to the specific endpoint
Reuse a proof after modifying the system clock forward by two minutesHTTP 401, iat outside the acceptable window
Send an /auth request directly with scope parameters instead of a pushed request_uriKeycloak rejects it because PAR is required for this client

Common Failure Modes

SymptomLikely CauseResolution
Token is issued but has no cnf/jkt claimThe dpop feature is not enabled on the server, or dpop.bound.access.tokens is not set on the clientRe-verify both parts of Step 2.2
Every token call returns use_dpop_nonce and never succeedsThe client is not echoing the returned nonce into the retried proofConfirm the nonce from the DPoP-Nonce header is passed into build_proof
Intermittent nonce failures under repeated callsDPoP nonce state is per node; ib-lb routed a retry to a different node than issued the nonceEnsure 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 mismatchThe htu in the proof does not exactly match the URL the server compares against, including scheme and portAlign the htu string in the client with the exact URL string in resource_server.py

09Security Analysis

What Makes This Implementation Secure

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
Proof replay windowPersist recently seen proof jti values with a TTL matching the accepted iat window, and reject any repeat within it
Nonce enforcementRequire server-issued nonces on both the token and resource endpoints, and ensure clients handle the challenge and retry across an HA cluster
Key storageStore the client DPoP private key in platform secure storage or an HSM, never in a world-readable file
Algorithm allowlistRestrict accepted proof algorithms to a vetted set (for example ES256, ES384) and reject symmetric or none algorithms outright
Clock skewApply 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
VERIFICATION Confirm the resource server process is stopped and no token files remain with 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

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?

Next: IB-SIA-11, Token Exchange (RFC 8693)
With sender-constrained tokens in place, Lab 11 tackles delegation: exchanging one token for another to cross trust boundaries safely, the mechanism behind service-to-service impersonation and downscoping.