Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

  1. Complete a normal Token Decoder sign-in as Ava

    Recorded as oauth.token succeeded about [email protected].

  2. Set lab-printer's PKCE to Optional for one step

    Recorded as tenant.oauth.clients.update succeeded.

  3. See a verifier refused for a code issued without a challenge

    Recorded as oauth.token rejected (pkce_failed) for lab-printer.

  4. Set lab-printer's PKCE back to Required

    Recorded as tenant.oauth.clients.update succeeded.

  5. See a request without a challenge refused

    Recorded as oauth.authorize rejected (pkce_required) for lab-printer.

Setup

  1. Choose Lab Photos as the lab tenant and press Start.

  2. Open two separate browser contexts, for example a normal window (A) and a private window or second profile (B). Open $ISSUER/token-decoder in 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 checks state and iss before it redeems anything.

  3. Make sure Ben has a password (User Management), and load ISSUER, CLIENT_ID and CLIENT_SECRET for lab-printer in your shell.

Walkthrough

  1. Plant the other browser's state. In B, start a decoder sign-in. When the sign-in page appears, copy the state value from the authorization request in the address bar (it is inside the request), then close that page without signing in.

  1. 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.

  1. Complete a normal decoder sign-in in A as Ava. The decoder lists its response checks: state and iss matched.

  1. Confirm in Audit that step 2 produced no oauth.token event, and step 3 produced one.

Why it matters: a refused callback must have sent nothing to the token endpoint.

  1. 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, is Secure, HttpOnly, SameSite=Lax and 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.

  1. 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). Run btl-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.

  1. With PKCE Required, open the same URL without a challenge again. The callback carries error=invalid_request, and Audit shows oauth.authorize rejected with pkce_required.

  1. 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.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab