IDENTITY SECURITY · LAB
Write a scope, run negative tests against your own tenant and check detection
Write a test scope for your lab tenant, run a small negative test script whose every request should be refused, confirm each refusal is recorded with a reason, and measure how long detection takes.
Partly readyUses your lab tenant
The lesson
Builds on: Reviewing identity security posture.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G36 Detection, risk signals, tenant metrics and alerts (baselines, anomaly rules, thresholds that notify an admin)
- G38 Method-aware step-up: require a phishing-resistant method for sensitive actions, not just a recent sign-in
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.
A code redeemed with the wrong verifier is refused
Recorded as
oauth.tokenrejected (pkce_failed) forlab-printer-app.A refresh that widens scope is refused
Recorded as
oauth.tokenrejected (scope_not_granted) forlab-printer-app.Revoking another client's token is refused
Recorded as
oauth.revokerejected (other_client_token) forlab-photo-api.A code redeemed twice is refused
Recorded as
oauth.tokenrejected (code_replayed) forlab-printer-app.A wrong client secret is refused
Recorded as
oauth.tokenrejected (invalid_client) forlab-print-orders.A stale session cannot add a sign-in method
Recorded as
account.securityrejected (reauthentication_required).
Setup
Press Start on this page.
Write the test scope before sending anything, in the lesson's format:
Authorized by: you, as the owner of the Lab Photos tenant.
In scope: Lab Photos only, its test people (Ava, Ben, Cora, Mia) and its
lab-clients.Out of scope: every other tenant, the BTL sites and services themselves, real people's accounts, and anything that sends many requests or tries to overload a service.
Stop and report: anything that looks like real data or real activity you did not cause.
Why it matters: having an account on a service does not authorize testing the service. Every request here is an ordinary request to a tenant you own, with test people, and a handful of requests per test.
Make sure these are in your shell:
CLIENT_ID(lab-printer-app),ORDERS_IDandORDERS_SECRET(lab-print-orders),API_IDandAPI_SECRET(lab-photo-api).Define a small helper that compares what you expected with what you got:
expect() { if [ "$2" = "$3" ]; then echo "PASS $1"; else echo "FAIL $1: expected $2, got $3"; fi; }
Walkthrough
Test: a code redeemed with the wrong verifier. Run the
lab-printer-appauthorization flow as Ava (witheval "$(btl-lab pkce)"andeval "$(btl-lab state)"), read the code from the callback page intoCODE, then make a fresh pair so the verifier no longer matches, and redeem.
read -rs CODE
eval "$(btl-lab pkce)"
got=$(curl -s -d grant_type=authorization_code -d client_id="$CLIENT_ID" --data-urlencode code="$CODE" \
--data-urlencode redirect_uri=https://beyondthelogin.dev/lab/callback/ -d code_verifier="$VERIFIER" "$ISSUER/oauth/token" | jq -r .error)
expect "code with wrong verifier" invalid_grant "$got"
Why it matters: most tests confirm the right thing works. This one confirms the wrong thing fails, which is the path an attacker would take.
Run the flow again for code B and redeem it correctly, keeping the tokens. Then test that a refresh cannot widen scope beyond Ava's grant.
read -rs CODE
RESPONSE=$(curl -s -d grant_type=authorization_code -d client_id="$CLIENT_ID" --data-urlencode code="$CODE" \
--data-urlencode redirect_uri=https://beyondthelogin.dev/lab/callback/ -d code_verifier="$VERIFIER" "$ISSUER/oauth/token")
TOKEN=$(echo "$RESPONSE" | jq -r .access_token); REFRESH=$(echo "$RESPONSE" | jq -r .refresh_token)
got=$(curl -s -d grant_type=refresh_token -d client_id="$CLIENT_ID" --data-urlencode refresh_token="$REFRESH" -d "scope=photos.read photos.write" "$ISSUER/oauth/token" | jq -r .error)
expect "refresh widening scope" invalid_scope "$got"
Test: another client trying to revoke Ava's token, and then the same code redeemed a second time.
got=$(curl -s -u "$API_ID:$API_SECRET" --data-urlencode token="$TOKEN" "$ISSUER/oauth/revoke" | jq -r .error)
expect "revoke another client's token" unauthorized_client "$got"
got=$(curl -s -d grant_type=authorization_code -d client_id="$CLIENT_ID" --data-urlencode code="$CODE" \
--data-urlencode redirect_uri=https://beyondthelogin.dev/lab/callback/ -d code_verifier="$VERIFIER" "$ISSUER/oauth/token" | jq -r .error)
expect "code redeemed twice" invalid_grant "$got"
Why it matters: each refusal must be specific and must hold when the request goes straight to the endpoint, without any page in front of it.
Test: a wrong client secret for the print job.
got=$(curl -s -u "$ORDERS_ID:not-the-secret" -d grant_type=client_credentials -d scope=prints.create "$ISSUER/oauth/token" | jq -r .error)
expect "wrong client secret" invalid_client "$got"
Test: a stale session adding a sign-in method. Sign in as Cora, wait more than 10 minutes, and try to add an authenticator app at
$ISSUER/account/security. Record PASS if the page asks you to confirm it is you.
Why it matters: the lesson's table also lists a fresh session from a code sign-in where policy requires a passkey. Your tenant cannot express that case yet, so it stays as a written test that currently has no control to pass.
Check what each refusal left behind. In Audit, source Protocol activity, filter
rejectedand find one event per test, each with its reason:pkce_failed,scope_not_granted,other_client_token,code_replayed,invalid_clientandreauthentication_required. Open one and confirm it holds no secret, code or token.
Why it matters: a refusal is worth checking for what it records as well as what it returns. A refusal that leaves no trace cannot be investigated.
Detection test. Re-run your Rule 2 from the detection lab against step 5's events: did Cora sign in and add a method within 10 minutes? Write down how long it took you to notice by reading Audit by hand.
Why it matters: detection breaks silently. Measuring time to notice, every time you run the tests, shows whether it is getting faster or quietly stopped working.
Rehearse the incident: spend 15 minutes on a tabletop with the lesson's scenario applied to Ava. Who in your tenant can lock her, reset her methods and revoke her app's tokens, and how long does each take? Add the complication: the password was reset, and the attacker is back an hour later. Write each gap with an owner and a date.
Why it matters: some parts of a response depend on people knowing what to do. A step only one person knows how to do is a finding, recorded like any other.
Break it
Change one expectation to the wrong value, for example
expect "wrong client secret" access_token_found "$got", and run step 4 again. The helper prints FAIL. A test that cannot fail proves nothing; correct the expectation and run it once more.
Check your work
Check my progress confirms all six refusals, in the order you ran them.
Every line your script printed says PASS, and your notes record step 5's result, the detection time and the tabletop gaps.
Cleanup
Revoke Ava's remaining lab tokens and clear the shell:
unset CODE TOKEN REFRESH RESPONSE.Keep the scope and the script; run them again after any change to the tenant's settings.
Missing infrastructure
G36: there are no alerts, so detection time is the time it takes you to read Audit. Once it exists, the lab will repeat step 5 and measure the time until the alert arrives.
G38: the tenant cannot require a passkey confirmation for sensitive changes, so the lesson's "fresh session from a code sign-in" case has no control to test. Once it exists, the test will expect a passkey prompt for Cora's new method.