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

Make your photo API refuse tokens meant for another API

Run a local photo API that knows its own identifier, accept a token addressed to it, and refuse an active token from the same issuer and key that names the print API.

Partly readyUses your lab tenant

The lesson

Builds on: Selecting the intended resource.

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.

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. Get a photo API token for Ava through lab-printer

    Recorded as oauth.token succeeded for lab-printer.

  2. Get a print API token through lab-print-orders

    Recorded as oauth.token succeeded for lab-print-orders.

  3. Let the photo API introspect a token issued to another client

    Recorded as oauth.introspect succeeded (other_client_token_found) for lab-photo-api.

  4. Try to revoke lab-print-orders' token as lab-printer

    Recorded as oauth.revoke rejected (other_client_token) for lab-printer.

  5. Revoke lab-printer's own token

    Recorded as oauth.revoke succeeded (access_token_found) for lab-printer.

Setup

  1. In Lab Photos, confirm OAuth > Flow policy allows authorization_code and client_credentials.

  2. Confirm lab-photo-api exists: confidential, with Resource server on, so it may introspect access tokens issued to other clients in this tenant. Create it if you skipped the OAuth core track.

  3. In OAuth > Access token managers, create lab-tmp-at-prints (JWT, ES256) with the standard aud claim overridden to https://prints.lab.example, and assign it to lab-print-orders. If you kept it from the previous lab, reuse it. lab-printer keeps the tenant default manager, whose audience is $ISSUER/resource, the photo API's identifier in this track.

  4. Store the three clients' credentials in the shell. btl-lab introspect and btl-lab resource read API_ID and API_SECRET:

export CLIENT_ID='<lab-printer client id>'; read -rs CLIENT_SECRET; export CLIENT_SECRET
export ORDERS_ID='<lab-print-orders client id>'; read -rs ORDERS_SECRET; export ORDERS_SECRET
export API_ID='<lab-photo-api client id>'; read -rs API_SECRET; export API_SECRET
  1. Press Start on this page with Lab Photos selected.

Walkthrough

  1. Start the photo API with its own identifier. It validates tokens by introspection as lab-photo-api and logs one line per request:

btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource"

Why it matters: the API knows its identifier from configuration, never from the request.

  1. A token for this API. In another terminal, start btl-lab callback, then run an ordinary code flow for lab-printer:

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&code_challenge=$CHALLENGE&code_challenge_method=S256"

Approve as Ava, check that state and iss match, then:

read -rs CODE
TOKEN=$(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 --data-urlencode "code_verifier=$VERIFIER" | jq -r .access_token); unset CODE
btl-lab decode "$TOKEN"
curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos

The token's aud is <your ISSUER>/resource; the API returns 200 and logs allowed.

Why it matters: the token names this API, so it passes the exact comparison.

  1. A token for another API, from the same issuer and the same kind of key:

ORDERS_TOKEN=$(curl -s -u "$ORDERS_ID:$ORDERS_SECRET" "$ISSUER/oauth/token" -d grant_type=client_credentials -d scope=prints.create | jq -r .access_token)
btl-lab decode "$ORDERS_TOKEN"
curl -s -i -H "Authorization: Bearer $ORDERS_TOKEN" http://127.0.0.1:8766/photos | grep -i -E '^HTTP|www-authenticate'

The header shows the same iss and an ES256 signature, aud is https://prints.lab.example, and the API answers 401 with WWW-Authenticate: Bearer error="invalid_token", logging a wrong audience.

Why it matters: there is no partial credit for a token from the same authorization server. aud may be a string or an array, and the API accepts the token only when its own identifier is in it.

  1. Active is not enough. Ask the authorization server about the print token, as the photo API does:

btl-lab introspect "$ORDERS_TOKEN"

The response says "active": true, with aud https://prints.lab.example and the print orders client as client_id. The API still refuses it.

Why it matters: introspection says the server still honors the token, not that it was issued for this API. An API that relies on introspection compares the aud member exactly as it would the claim.

  1. The request cannot choose the expected value:

curl -s -i -H 'Host: prints.lab.example' -H "Authorization: Bearer $ORDERS_TOKEN" http://127.0.0.1:8766/photos | head -n 1

It is still 401.

Why it matters: the caller controls the Host header, so the API never derives its identifier from it.

  1. The second question: scopes. Change lab-tmp-at-prints so its aud override is the list ["https://prints.lab.example", "<your ISSUER>/resource"] and save. Get a new ORDERS_TOKEN as in step 3 and call the photo API again. The audience now matches, but the API answers 403 with error="insufficient_scope", because prints.create means nothing at the photo API.

Restore: set the aud override on lab-tmp-at-prints back to the single value https://prints.lab.example and save.

Why it matters: a token that names this API but carries only another API's scopes authorizes nothing here. A token with several audiences also means another API holds a working copy, which an API may refuse or accept only for less sensitive operations.

  1. What the audience does not cover. Copy $TOKEN into a different terminal and call the API from there. It works for anyone who holds it, until it expires or is revoked.

Why it matters: the audience limits where a token works, not who presents it. Binding, through DPoP or mutual TLS, answers who.

Planned walkthrough

These steps need request-time audiences (G16) and the tenant resource registry (G55).

  1. With the registry from Name the API you want a token for, lab-printer names the photo API with resource=$ISSUER/resource and lab-print-orders names resource=https://prints.lab.example, so each token's audience comes from the request instead of a per-client manager. One client can then hold tokens for both APIs.

  2. The counterfeit API. A client that lets a user type an API address sends resource=https://photos-api.unknown.example. Lab Photos does not recognize it and refuses with invalid_target at the token endpoint, or through the browser with state and iss at the authorization endpoint. Audit records oauth.token rejected invalid_target (planned reason). Even a careless server that issued such a token would write the unknown address into aud, and the photo API in step 3 above would refuse it.

Break it

  1. Misconfigure the API. Stop it and restart it with a trailing slash:

btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource/"

Ava's genuine token from step 2 is now refused as a wrong audience.

Restore: restart the API with --audience "$ISSUER/resource". The comparison is exact; fix the configuration, never add prefix or slash tolerance.

  1. Revocation belongs to the token's own client. As lab-printer, try to revoke the print orders token, then revoke your own:

curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/revoke" --data-urlencode "token=$ORDERS_TOKEN" | jq .
curl -s -o /dev/null -w '%{http_code}\n' -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/revoke" --data-urlencode "token=$TOKEN"
curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos | head -n 1

The first is refused with unauthorized_client, the second returns 200, and the API now refuses Ava's token because introspection reports it inactive. An API validating the JWT only locally against the key set would keep accepting it until exp.

Why it matters: introspection sees revocation; local validation trades that for speed.

Check your work

Press Check my progress. The checks look in Lab Photos for the two token requests, the photo API's introspection of another client's token, the refused revocation of the print token, and the revocation of Ava's token. The photo API's own log shows allowed, a wrong audience, insufficient scope and an inactive token.

Cleanup

Stop the photo API. Confirm lab-tmp-at-prints has a single audience. Assign lab-print-orders back to the access token manager it used before and delete lab-tmp-at-prints. Run unset TOKEN ORDERS_TOKEN.

Missing infrastructure

  • G16 Resource indicators and G55 Tenant resource (API) registry: today the audience is fixed per client by its access token manager, so one client cannot get tokens for two APIs, and the tenant cannot refuse an unknown resource with invalid_target. With both, the planned steps run as written.

  • G3 Sample protected resource API: a hosted photo API at $ISSUER/resource would let learners run the audience check without the local btl-lab resource, which remains useful for seeing the check itself.

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