OAUTH 2.0 · LAB
Confirm a callback is bound to the browser that started it
Watch a real client refuse a callback whose state belongs to another browser, inspect the cookies that carry the binding, and confirm the tenant refuses a PKCE downgrade.
ReadyUses your lab tenant
The lesson
Builds on: Public and confidential clients.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Complete a normal Token Decoder sign-in as Ava
Recorded as
oauth.tokensucceeded about[email protected].Set lab-printer's PKCE to Optional for one step
Recorded as
tenant.oauth.clients.updatesucceeded.See a verifier refused for a code issued without a challenge
Recorded as
oauth.tokenrejected (pkce_failed) forlab-printer.Set lab-printer's PKCE back to Required
Recorded as
tenant.oauth.clients.updatesucceeded.See a request without a challenge refused
Recorded as
oauth.authorizerejected (pkce_required) forlab-printer.
Setup
Choose Lab Photos as the lab tenant and press Start.
Open two separate browser contexts, for example a normal window (A) and a private window or second profile (B). Open
$ISSUER/token-decoderin each. The Token Decoder is a real public client built into the tenant: it keeps each pending sign-in in its own tab's session storage and checksstateandissbefore it redeems anything.Make sure Ben has a password (User Management), and load
ISSUER,CLIENT_IDandCLIENT_SECRETforlab-printerin your shell.
Walkthrough
Plant the other browser's state. In B, start a decoder sign-in. When the sign-in page appears, copy the
statevalue from the authorization request in the address bar (it is inside the request), then close that page without signing in.
Deliver a callback A did not start. In A, start a decoder sign-in and cancel at the sign-in page, so A has its own pending attempt. Then open this address in A, with B's state and your issuer URL-encoded:
$ISSUER/token-decoder?code=unused&state=<B's state>&iss=<your issuer, URL-encoded>
The decoder reports that the response does not match a request started from this page, and sends no token request.
Why it matters: a forged callback carries someone else's attempt. Only a lookup among this browser's own pending attempts catches it, and the check runs before the code goes anywhere. A client that searched every pending attempt at once would have found B's and continued.
Complete a normal decoder sign-in in A as Ava. The decoder lists its response checks:
stateandissmatched.
Confirm in Audit that step 2 produced no
oauth.tokenevent, and step 3 produced one.
Why it matters: a refused callback must have sent nothing to the token endpoint.
Inspect the cookies that carry the binding. Start a decoder sign-in in A and, before signing in, open developer tools, Application, Cookies for your tenant host. The tenant's own sign-in transaction cookie,
__Host-btl-oauth-tx, isSecure,HttpOnly,SameSite=Laxand short-lived.
Why it matters: a callback is a navigation another site started. A Lax cookie arrives with it and a Strict one does not, so the short-lived attempt cookie is Lax while a session cookie can stay Strict. Lax does not stop forged navigations; state and PKCE do, and the cookie gives them something to compare with.
Confirm the authorization server refuses a PKCE downgrade. In Clients, set
lab-printer's PKCE for confidential clients to Optional and save (this revokes its tokens). Runbtl-lab callback, then request a code without any challenge:
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=photos.read&state=$STATE"
Sign in as Ava, copy the code into CODE, and redeem it with a verifier anyway:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=http://127.0.0.1:8765/callback" -d "code_verifier=$VERIFIER" | jq .
Returns invalid_grant, Audit reason pkce_failed.
Why it matters: an attacker can leave the challenge out of their own request and receive a code with no binding. A server that accepted the victim's verifier for it would let PKCE be skipped silently, so the tenant refuses a verifier presented for a code issued without a challenge.
Restore: set lab-printer's PKCE for confidential clients back to Required and save.
With PKCE Required, open the same URL without a challenge again. The callback carries
error=invalid_request, and Audit showsoauth.authorizerejected withpkce_required.
Check that a state-changing endpoint refuses a plain link. The tenant's sign-out accepts only POST:
curl -s -o /dev/null -w '%{http_code}\n' "$ISSUER/logout"
Returns 405. A link or image on another site cannot sign anyone out.
Why it matters: Connect and Disconnect change state too. Accept them only as POST, with SameSite cookies plus something another site cannot supply, and take the service and scope from the client's own configuration.
Break it
Step 6 temporarily sets PKCE to Optional for one client, and its Restore callout sets it back. Saving each change revokes lab-printer's tokens, which is expected.
Check your work
Press Check my progress. The checks look for, in order: the decoder sign-in for Ava, the change to Optional, pkce_failed for lab-printer, the change back to Required, and oauth.authorize rejected with pkce_required.
There should be no oauth.token event between the forged callback in step 2 and the normal sign-in in step 3.
Cleanup
Confirm lab-printer shows PKCE for confidential clients Required. Close window B.