Zero Trust Architecture With Http Dpop Proof Binding For Ci Ephemeral Tokens
Written by
Vera Crypt
I fell into this rabbit hole because I kept seeing the same annoying pattern in my homelab: my CI pipeline would mint short-lived API tokens, those tokens would get used successfully, and then—without any “real” breach—I’d still be able to replay requests by copying the bearer token into a different machine. That’s the moment it clicked: Zero Trust isn’t just “short-lived tokens.” It’s also about binding proof of the caller to where and how the request was made.
So I built a small, working prototype: a Zero Trust-ish “request authentication layer” where the client sends an DPoP proof—DPoP stands for Demonstration of Proof of Possession, meaning the client proves it controls a private key associated with the token. The server verifies that proof, and also checks that the proof is bound to the specific request details (method + path). That prevents a stolen bearer token from being trivially replayed elsewhere.
This is intentionally niche: DPoP proof binding for CI ephemeral tokens, enforced at the edge using a minimal Flask middleware.
The problem: short-lived bearer tokens still replay
In the simplest setup, a CI job gets a bearer token:
- Authorization header:
Authorization: Bearer <token> - Server checks token signature/expiry
- Server accepts the request
Even if the token expires quickly, an attacker who captures it can replay the HTTP request during the token’s lifetime. In my tests, replaying the exact request from a different host worked.
What I wanted instead: proof that the caller is the holder of a private key, not just someone who has a token.
What I built: DPoP-bound tokens enforced on the server
Key idea
Each request includes:
Authorization: DPoP <access_token>DPoP: <jwt_proof>
The DPoP proof is signed by a private key held by the CI job. The server verifies:
- the DPoP JWT signature using the public key embedded in the proof (
jwk) - that the proof
htu(HTTP URL hash) andhtm(HTTP method) match the actual request - that the proof includes a unique
jti(JWT ID) which the server rejects after first use (simple replay defense)
This turns “token replay” into “token replay plus proof-of-private-key”, which is much harder.
Step 1: Generate a keypair for the CI job
In a real CI environment, you’d generate this key per job (ephemeral). Here’s a tiny script that generates an EC keypair and produces a JWK for the DPoP proof.
# gen_keypair.py import base64 import json from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).decode().rstrip("=") private_key = ec.generate_private_key(ec.SECP256R1()) public_key = private_key.public_key() numbers = public_key.public_numbers() jwk = { "kty": "EC", "crv": "P-256", "x": b64url(numbers.x.to_bytes(32, "big")), "y": b64url(numbers.y.to_bytes(32, "big")), } pem_private = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) pem_public = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) print("JWK:", json.dumps(jwk)) print("PRIVATE_PEM:", pem_private.decode()) print("PUBLIC_PEM:", pem_public.decode())
Why this matters: DPoP needs the server to learn the public key from the proof itself (or from a registry). For a PoC, embedding the public key in the JWT proof is the simplest path.
Step 2: Mint an access token bound to DPoP (server-side expectation)
I used a short-lived HMAC-signed “access token” to keep the demo focused (in production you’d use OAuth 2.0 / OIDC and proper key management). The only important part is that the server expects the Authorization scheme to be DPoP (not Bearer) and treats the token as “requires DPoP proof.”
Here’s a simple token minting endpoint.
# server.py (token endpoint) from flask import Flask, request, jsonify import time import jwt app = Flask(__name__) ACCESS_TOKEN_SECRET = "change-me" DPoP_PROOF_AUD = "urn:demo-dpop" # demo audience @app.post("/mint") def mint(): # CI job would authenticate using its own mechanism here. # For the demo, we skip it. payload = { "iss": "demo-ci", "aud": "demo-api", "sub": "ci-job-123", "exp": int(time.time()) + 60, # 60s lifetime "iat": int(time.time()), "dpop": True, # marker: server should expect DPoP proof } token = jwt.encode(payload, ACCESS_TOKEN_SECRET, algorithm="HS256") return jsonify({"access_token": token, "token_type": "DPoP", "aud": "demo-api"})
Why the marker: it’s a cheap way to force “DPoP-required” semantics without implementing the full OAuth stack.
Step 3: Verify DPoP proofs bound to method + path + anti-replay
The heart of the Zero Trust enforcement is the middleware that runs before your handler.
DPoP claims I verify
A DPoP proof JWT commonly includes:
htm: HTTP method (e.g.,POST)htu: HTTP URL (or hash representation; here I used the full URL string for simplicity)jti: unique proof id (used for replay detection)iat: issued-at timeaud: audience (server)typ: token type (not strictly required in this demo)jwk: the public key parameters for EC verification- signature: verified with that
jwk
Anti-replay
I keep an in-memory set of used jti values for a short window.
Note: for a real system, you’d store
jtiin Redis or another shared datastore across instances.
# server.py (full example) from flask import Flask, request, jsonify import time import jwt from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization app = Flask(__name__) ACCESS_TOKEN_SECRET = "change-me" DPoP_PROOF_AUD = "urn:demo-dpop" used_jti = {} # jti -> exp time def b64url_to_int(s: str) -> int: import base64 data = base64.urlsafe_b64decode(s + "===") # tolerant padding return int.from_bytes(data, "big") def jwk_to_public_key(jwk: dict): if jwk["kty"] != "EC" or jwk["crv"] != "P-256": raise ValueError("Only P-256 supported in this demo") x = b64url_to_int(jwk["x"]) y = b64url_to_int(jwk["y"]) public_numbers = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()) return public_numbers.public_key() def cleanup_used_jti(now: int): expired = [jti for jti, exp in used_jti.items() if exp <= now] for jti in expired: used_jti.pop(jti, None) @app.before_request def dpop_protect(): # Protect only the API endpoint(s) for demo. if request.path != "/api/data": return authz = request.headers.get("Authorization", "") if not authz.startswith("DPoP "): return jsonify({"error": "missing_or_invalid_authorization_scheme"}), 401 access_token = authz.split(" ", 1)[1].strip() dpop_jwt = request.headers.get("DPoP") if not dpop_jwt: return jsonify({"error": "missing_dpop_proof"}), 401 # 1) Validate access token (signature + expiry + aud/sub) try: token = jwt.decode( access_token, ACCESS_TOKEN_SECRET, algorithms=["HS256"], audience="demo-api", issuer="demo-ci", ) except Exception as e: return jsonify({"error": "invalid_access_token", "detail": str(e)}), 401 if not token.get("dpop"): return jsonify({"error": "dpop_required"}), 401 # 2) Validate DPoP proof JWT try: proof = jwt.decode(dpop_jwt, options={"verify_signature": False}) except Exception as e: return jsonify({"error": "invalid_dpop_jwt", "detail": str(e)}), 401 jwk = proof.get("jwk") if not jwk: return jsonify({"error": "missing_jwk_in_dpop_proof"}), 401 # method + url binding htm = proof.get("htm") htu = proof.get("htu") aud = proof.get("aud") jti = proof.get("jti") iat = proof.get("iat") if htm != request.method: return jsonify({"error": "htm_mismatch"}), 401 # Build the URL string the client is expected to hash/bind to. # In this demo, I compare full URL including scheme/host as received. expected_htu = request.url if htu != expected_htu: return jsonify({"error": "htu_mismatch", "expected": expected_htu, "got": htu}), 401 if aud != DPoP_PROOF_AUD: return jsonify({"error": "aud_mismatch"}), 401 if not jti or not isinstance(jti, str): return jsonify({"error": "missing_jti"}), 401 now = int(time.time()) cleanup_used_jti(now) # basic iat freshness window if not iat or abs(now - int(iat)) > 60: return jsonify({"error": "iat_out_of_range"}), 401 # replay detection if jti in used_jti: return jsonify({"error": "replay_detected"}), 401 # verify signature with public key from jwk try: public_key = jwk_to_public_key(jwk) pem_public = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) # PyJWT wants a key object or PEM jwt.decode( dpop_jwt, pem_public, algorithms=["ES256"], options={"verify_aud": False}, # we do aud manually above ) except Exception as e: return jsonify({"error": "dpop_signature_invalid", "detail": str(e)}), 401 # mark jti as used for a short TTL used_jti[jti] = now + 120 @app.post("/api/data") def api_data(): return jsonify({"ok": True, "message": "Zero Trust DPoP proof accepted."}) if __name__ == "__main__": # For local demo only. app.run(host="127.0.0.1", port=5000, debug=False)
Why this works (in practice):
- If someone copies the access token, they still need the private key to sign a fresh DPoP proof.
- If they capture a DPoP proof and replay it,
jtigets rejected. - If they alter the method/path,
htm/htumismatches and the server denies it.
Step 4: Client code that sends a DPoP proof for the request
Now I created a client that:
- Requests an access token from
/mint - Builds a DPoP proof JWT for
POST /api/dataincludinghtmandhtu - Signs it with the CI job private key
- Sends the request with both headers
# client.py import time import uuid import jwt import requests from cryptography.hazmat.primitives import serialization ACCESS_TOKEN_SECRET = "change-me" DPoP_PROOF_AUD = "urn:demo-dpop" # Paste your PRIVATE_PEM from gen_keypair.py here PRIVATE_PEM = """-----BEGIN PRIVATE KEY----- REPLACE_ME -----END PRIVATE KEY-----""" def make_dpop_proof(private_pem: str, url: str, method: str, jwk: dict) -> str: now = int(time.time()) payload = { "htu": url, "htm": method, "aud": DPoP_PROOF_AUD, "jti": str(uuid.uuid4()), "iat": now, "typ": "dpop+jwt", "jwk": jwk, # public parameters } # Sign using ES256 with the private key. return jwt.encode(payload, private_pem, algorithm="ES256") def main(): base = "http://127.0.0.1:5000" # 1) Mint token (demo) r = requests.post(f"{base}/mint") r.raise_for_status() token = r.json()["access_token"] # 2) Create DPoP proof # The server compares htu to the full request.url, so we must match exactly. api_url = f"{base}/api/data" # The jwk must match the public key corresponding to PRIVATE_PEM. # Paste your JWK from gen_keypair.py here: jwk = { "kty": "EC", "crv": "P-256", "x": "REPLACE_ME", "y": "REPLACE_ME", } dpop = make_dpop_proof(PRIVATE_PEM, api_url, "POST", jwk) # 3) Call protected API headers = { "Authorization": f"DPoP {token}", "DPoP": dpop, } rr = requests.post(api_url, headers=headers, json={"hello": "world"}) print("status:", rr.status_code) print(rr.text) if __name__ == "__main__": main()
Step 5: Run it and watch replay fail
Terminal A: start the server
python server.py
Terminal B: run the client once
python client.py
You should see something like:
{"ok": true, "message": "Zero Trust DPoP proof accepted."}
Terminal C: attempt replay by reusing the same proof
To test replay protection, modify the client to print the generated dpop value, copy it, and send the same request again within the 120s window. The second attempt should get:
{"error":"replay_detected"}with HTTP401
Even worse for an attacker: copying only the access token and generating no DPoP proof will also be denied with:
{"error":"missing_dpop_proof"}
Practical takeaways I learned building this
- Zero Trust is about binding, not just limiting time. Short-lived tokens reduce blast radius, but replayable bearer semantics still leave a practical gap.
- Proof-of-possession changes the attacker model. A token alone is data; a DPoP proof turns it into “data + private key possession.”
- Request binding matters. Verifying
htmandhtustops “valid proof for one endpoint” being reused for another. - Anti-replay requires tracking
jti. Stateless verification is not enough if proofs can be reused verbatim.
I started this thinking “token lifetime is the security lever,” but what I ended up with is a more Zero Trust-aligned pattern: CI issues ephemeral credentials, and every request must include cryptographic proof that’s bound to the exact request and rejected after reuse.