OAUTH 2.0 · LAB
Add freshness, jti tracking and server nonces to DPoP proofs
Turn on DPoP nonces, handle use_dpop_nonce as a client by retrying once with a fresh proof, and see why every retry needs a new jti. Today, see how identical bearer requests look to the tenant.
PlannedUses your lab tenant
The lesson
Builds on: Validating proofs at the server.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.
- G14 DPoP
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
Setup
Keep
app-key.pem,app-key.jwk, theb64urlhelper and thelab-printer-appvariables from the earlier DPoP labs.Planned (G14): in OAuth > Flow policy > DPoP, set proof age to 60 seconds, clock tolerance to 5 seconds,
jtitracking on, nonces for Token endpoint and UserInfo, and nonce rotation every 5 minutes. Both stores appear as usage policies in the tenant's usage view.
Note: the lab toolkit has no command yet that signs a DPoP proof with your own key. The planned steps say exactly what each proof contains; they need that command before they can run.
Planned walkthrough
Redeem a code for
lab-printer-appwith a proof that has nononceclaim.
curl -si "$ISSUER/oauth/token" -H "DPoP: $PROOF" -d grant_type=authorization_code -d code=<code> \
--data-urlencode redirect_uri=$REDIRECT -d code_verifier=$VERIFIER -d client_id=$APP_ID
Expect 400 {"error":"use_dpop_nonce"} with a DPoP-Nonce header. Save its value as AS_NONCE.
Why it matters: "Server-provided nonces". Nobody can sign a proof containing a value the server has not yet revealed, so proofs cannot be made in advance.
Retry once with a fresh proof: a new
jti, a newiat, and"nonce": "$AS_NONCE". Tokens are issued.
Why it matters: the client retries exactly once, with a new proof, never by resending the old one.
Call UserInfo with a proof that carries
AS_NONCE. Expect401withWWW-Authenticate: DPoP error="use_dpop_nonce"and a differentDPoP-Nonce. Retry with that value.
Why it matters: each server's nonce is accepted only by that server, so the client keeps one per server and never swaps them.
Keep calling UserInfo. When a successful response carries a new
DPoP-Nonce, switch to it for the next proof.
Why it matters: servers can rotate nonces on successful responses without costing the client a rejected request.
Turn nonces off and keep
jtitracking on. Send the same complete UserInfo request, same token and same proof, twice. The first is served; the second is refused.
Why it matters: "What a captured proof allows". A copied token and proof pair can be replayed until the time window closes, unless the server remembers jti values. That is also why a retry must always build a new proof, even for a safe GET.
Do today
Get a fresh
lab-printer-apptoken (see Bind the phone app's tokens to a key it generated, Do today step 2). Then send the identical UserInfo request five times.
for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code}\n' "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $TOKEN"; done
All five return 200, and Audit shows five oidc.userinfo succeeded userinfo_served events. Nothing distinguishes a repeat from a new request: this is the gap the lesson's time window, jti tracking and nonces close.
Assemble two UserInfo proof claim sets a second apart, as in Build DPoP proof contents and hashes by hand, Do today step 4. Compare
jtiandiat: a correct client gives every request its own.Work out the window a pre-made proof would have. Decode your token with
btl-lab decode "$TOKEN"and subtractiatfromexp. Becauseathties a proof to one token, proofs made in advance are worth at most that long, which is why the lesson ties long-lived bound tokens to nonces.For "What DPoP does not protect", write down two things a proof does not cover in your UserInfo call: the other headers and any request body. Note which layer protects them in transit.
Break it
Once G14 exists, simulate a client with a wrong clock by building a UserInfo proof whose iat is 120 seconds in the past. The tenant refuses it as stale. Nonces make freshness independent of the client's clock, because the server judges it from its own value.
Check your work
Today, Audit shows five oidc.userinfo succeeded userinfo_served events for lab-printer-app. Once G14 exists, Audit shows oauth.token refused with use_dpop_nonce followed by succeeded, an oidc.userinfo refusal of the replayed proof, and a refusal of the stale one.
Cleanup
Revoke the token:
curl -s "$ISSUER/oauth/revoke" -d token=$TOKEN -d client_id=$APP_ID.Once G14 exists, set the DPoP settings back to the values from the first DPoP lab.
When you finish the DPoP labs, delete
app-key.pemandapp-key.jwk.
Missing infrastructure
G14: nonce issuance and validation,
jtitracking with bounded storage, and the proof age policy. The nonce andjtistores should be registered usage policies the tenant can see.G3: a sample resource that issues its own nonces, so the lab can show a resource-side nonce beyond UserInfo.
Once these exist and the toolkit can sign proofs, the Planned walkthrough runs as written.