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

Present an access token correctly and read every refusal

Get a real token for Ava, send it the right way and three wrong ways, and read each RFC 6750 answer from UserInfo and a local photo API.

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. Get an access token for Ava as lab-printer

    Recorded as oauth.token succeeded for lab-printer about [email protected].

  2. Read Ava's profile with the token in the header

    Recorded as oidc.userinfo succeeded (userinfo_served) for lab-printer about [email protected].

  3. Present a token without openid to UserInfo

    Recorded as oidc.userinfo rejected (insufficient_scope) for lab-printer.

  4. Use a reference token from the temporary manager

    Recorded as oidc.userinfo succeeded (userinfo_served) for lab-printer.

Request console

Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.

Setup

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

  2. Confirm in the tenant portal that lab-printer exists (Confidential, redirect URI http://127.0.0.1:8765/callback, authorization code with PKCE) and uses the Default access tokens manager. If it does not exist, complete Register the printer twice first.

  3. Confirm Ava has a password you know. In User Management, set one if needed.

  4. Load the variables. The secret is read without echo and never placed in a URL or a file.

export ISSUER="https://tenant-<id>.beyondthelogin.dev"   # Lab Photos issuer, from Overview
export CLIENT_ID="<lab-printer client ID>"
read -rs CLIENT_SECRET
btl-lab env
  1. Define two helpers for this lab. authorize prints a fresh authorization URL with PKCE and state; exchange reads the code you paste and keeps the tokens in shell variables only.

authorize() {
  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=$(jq -rn --arg s "$1" '$s|@uri')&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
}
exchange() {
  read -r CODE
  RESP=$(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")
  TOKEN=$(jq -r .access_token <<<"$RESP"); jq 'del(.access_token, .refresh_token, .id_token)' <<<"$RESP"
}
  1. In a second terminal, run btl-lab callback. It listens on 127.0.0.1:8765, prints code, state and iss, then exits. Run it again before each sign-in.

Walkthrough

  1. Get a token for Ava. Run authorize "openid profile photos.read", open the URL, sign in as [email protected] and approve. Check that the printed state equals $STATE, then run exchange and paste the code.

{ "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile photos.read" }

Why it matters: the client learns the granted scope and the lifetime from the token response, never by reading the token.

  1. Turn expires_in into a time by your own clock, and plan to replace the token a minute early.

EXPIRES_AT=$(( $(date +%s) + $(jq .expires_in <<<"$RESP") )); REPLACE_AT=$(( EXPIRES_AT - 60 ))
date -d "@$REPLACE_AT" 2>/dev/null || date -r "$REPLACE_AT"

Why it matters: early replacement avoids a request that starts just before expiry and arrives just after it. It is an optimization; the client still handles a rejected token on every call.

  1. Present the token in the Authorization header. Open this request in the console, or run the same call with curl: curl -si "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $TOKEN".

GET$ISSUER/oidc/userinfo Open in console
GET $ISSUER/oidc/userinfo HTTP/1.1
Authorization: Bearer $TOKEN

Returns 200 with Ava's sub and profile claims.

Why it matters: the header is the one place every bearer API must support, and it keeps the token out of the address.

  1. Send no token at all: curl -si "$ISSUER/oidc/userinfo". Returns 401 with a bare WWW-Authenticate: Bearer and no error code.

Why it matters: with no credentials in the request there was nothing to evaluate, so the server adds no error details. The client's next step is to check that it really sent the token.

  1. Put the token in the query string: curl -si "$ISSUER/oidc/userinfo?access_token=$TOKEN". Returns 400 with error="invalid_request". The tenant refuses tokens in addresses outright.

Why it matters: an address travels into server logs, browser history, caches and Referer headers, each one a copy of the token nobody treats as secret.

  1. Send the token two ways at once: curl -si -X POST "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $TOKEN" --data-urlencode "access_token=$TOKEN". Returns 400 invalid_request.

Why it matters: a request carrying the token more than one way is malformed. It is a bug to fix, and sending it again will not help.

  1. Send a string that is not a token: curl -si "$ISSUER/oidc/userinfo" -H "Authorization: Bearer not-a-real-token". Returns 401 with error="invalid_token".

Why it matters: this is the one answer that means "get a new token and retry once". If the new token is refused too, the client stops and marks the connection as needing attention.

  1. Get a token without openid: authorize "photos.read", sign in, exchange, then call UserInfo with it. Returns 403 with error="insufficient_scope".

Why it matters: the token is valid but does not cover this operation. A fresh token for the same grant would fail the same way, so the client does not retry.

  1. Read the scope an API asks for. Start the local photo API in a third terminal with btl-lab resource --mode jwt. It trusts $ISSUER, expects the tenant's default audience $ISSUER/resource, and prints one decision line per request without the token. Call a route that needs photos.write with the same token:

curl -si -X POST http://127.0.0.1:8766/photos -H "Authorization: Bearer $TOKEN"

Returns 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="photos.write". GET /photos with the same token returns 200.

Why it matters: the scope attribute names what the operation needs, for the program to act on. Getting it means sending Ava through authorization again, where she can refuse; a refresh token can never add scope.

  1. Treat the format as private. In Access Token Management, create a manager lab-tmp-opaque (Token format: Opaque reference token) and assign it to lab-printer under Assign a client. Get a new token with openid profile photos.read. Call UserInfo as in step 3: still 200. Now run btl-lab decode "$TOKEN": there is nothing to decode.

Why it matters: the format is an agreement between the authorization server and the API. A client that read claims out of the JWT would have broken the moment the provider switched formats, while a client that relied on the token response kept working.

Restore: assign Default access tokens back to lab-printer.

  1. Keep the token where it belongs. Before following a link from a response, compare origins exactly, not by prefix:

node -e 'const api = new URL("https://api.photos.example"); for (const next of process.argv.slice(1)) { const u = new URL(next); console.log(u.origin === api.origin ? "send token" : "do not send", next, "| prefix check says", next.startsWith("https://api.photos.example")); }' \
  "https://api.photos.example/albums/42/photos?page=2" "https://api.photos.example.attacker.example/albums"

The second link passes a prefix check and fails the origin check.

Why it matters: a token goes only to the API it was issued for, at the configured address. Following a suggested link or redirect blindly is the easiest way to hand it to someone else.

Break it

  • Send the scheme in lower case: -H "Authorization: bearer $TOKEN". It succeeds, because the scheme name is case-insensitive. That is not a weakness; only the scheme's spelling varies.

  • Look at the local API's decision lines after steps 9 and 10. They show route, outcome, reason, client_id and jti, never the token. Compare with a request log that captures headers: that log would be a copy of every token.

Check your work

Press Check my progress. The checks look for, in order:

  • oauth.token succeeded for lab-printer with Ava as subject.

  • oidc.userinfo succeeded (userinfo_served) for Ava through lab-printer.

  • oidc.userinfo rejected with insufficient_scope for the token without openid.

  • oidc.userinfo succeeded again with the reference token.

The invalid_request and invalid_token refusals from steps 5 to 7 appear in Logs as protocol summaries, not in Audit, because an unknown or misplaced token identifies no client or user.

Cleanup

  1. Confirm lab-printer uses Default access tokens.

  2. Delete the lab-tmp-opaque manager.

  3. Stop btl-lab resource and btl-lab callback. Unset the token: unset TOKEN RESP CODE.

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