IDENTITY AND TRUST · LAB
Separate identifiers, shared secrets and key pairs
Watch a client secret travel with every request, compare an HMAC key with a key pair on your own machine, prove possession with a fresh challenge, and find the same ideas in your tenant's key set and Ava's passkey.
Partly readyUses your lab tenant
The lesson
Builds on: Giving another application limited access.
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.
- G8 Client authentication beyond `client_secret_basic` and `none`
- G14 DPoP
- G56 Client JWKS (`jwks` / `jwks_uri` per client)
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.
Present the job's identifier without its credential
Recorded as
oauth.tokenrejected (unsupported_auth_method) forlab-print-orders.Authenticate the job with its shared secret
Recorded as
oauth.tokensucceeded forlab-print-orders.Use a bearer token from a different terminal
Recorded as
oidc.userinfosucceeded (userinfo_served).Send the secret in a way the tenant does not accept
Recorded as
oauth.tokenrejected (unsupported_auth_method) forlab-print-orders.
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
In the lesson, the clinic portal's server calls an outside laboratory's API. Here lab-print-orders plays that server-to-server caller, and Lab Photos plays the verifier. You need openssl 3.x, the toolkit, and Ava with her passkey.
On this lab page choose Lab Photos and press Start.
Work in an empty folder for this lab's local files, and set the variables:
mkdir -p ~/btl-keys-lab && cd ~/btl-keys-lab
ISSUER=https://tenant-<id>.beyondthelogin.dev
ORDERS_ID=<lab-print-orders client ID>
read -rs ORDERS_SECRET
Walkthrough
What a credential is. Present the identifier alone, then the identifier with its credential:
curl -s -d grant_type=client_credentials -d client_id="$ORDERS_ID" "$ISSUER/oauth/token" | jq
curl -s -u "$ORDERS_ID:$ORDERS_SECRET" -d grant_type=client_credentials -d scope=prints.create "$ISSUER/oauth/token" | jq '{token_type, scope}'
Expected: 401 with invalid_client for the identifier alone, then a token.
Why it matters: the client ID says which client is calling and can be known by anyone. The secret is the evidence, and it is only useful while its holder keeps it from others.
Watch the shared secret travel. Rerun the second request with
-vand find theAuthorization: Basicheader. Decode just the part before the colon:
curl -sv -u "$ORDERS_ID:$ORDERS_SECRET" -d grant_type=client_credentials -d scope=prints.create "$ISSUER/oauth/token" 2>&1 >/dev/null \
| sed -n 's/^> Authorization: Basic //p' | tr -d '\r' | base64 -d | cut -d: -f1
The part after the colon is the secret itself, merely encoded.
Why it matters: with this method the shared secret goes with every request. TLS protects it in transit, but anyone who captures or logs one request can reuse it.
The verifier's copy. In Clients, look for a way to view
lab-print-orders's secret again. There is none, only Rotate secret.
Why it matters: because the holder sends the secret itself, the tenant can keep only a hash of it, just as it does for passwords.
Prove knowledge without sending the secret, with HMAC. The tenant does not accept HMAC-signed requests, so this runs locally:
LAB_KEY=$(openssl rand -hex 32)
printf 'GET /results/48213' | openssl dgst -sha256 -hmac "$LAB_KEY"
printf 'GET /results/48214' | openssl dgst -sha256 -hmac "$LAB_KEY"
Why it matters: the key never travels, and each request gets its own tag. But the verifier holding LAB_KEY can produce exactly the same tags, so a thief who steals the verifier's copy can make requests that look like the clinic's.
A key pair, locally:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out clinic.key
openssl pkey -in clinic.key -pubout -out clinic.pub
printf 'GET /results/48213' > req.txt
openssl dgst -sha256 -sign clinic.key -out req.sig req.txt
openssl dgst -sha256 -verify clinic.pub -signature req.sig req.txt
Expected: Verified OK. Now try to sign with the public key, openssl dgst -sha256 -sign clinic.pub -out x.sig req.txt: it fails, because a public key cannot sign.
Why it matters: the verifier holds only clinic.pub. Stealing it lets an attacker verify signatures, nothing more, so any number of verifiers can share it.
Prove possession with a fresh challenge. The verifier creates a challenge, the holder signs it, and the verifier checks it. Then the verifier creates a new challenge and checks the old answer against it:
openssl rand -hex 16 > challenge.txt
openssl dgst -sha256 -sign clinic.key -out answer.sig challenge.txt
openssl dgst -sha256 -verify clinic.pub -signature answer.sig challenge.txt
openssl rand -hex 16 > challenge.txt
openssl dgst -sha256 -verify clinic.pub -signature answer.sig challenge.txt
Expected: Verified OK, then Verification failure.
Why it matters: a fresh challenge makes a recorded answer useless. The key itself never left its holder.
Your tenant publishes only public keys:
GET$ISSUER/oauth/jwks
Open in console
GET $ISSUER/oauth/jwks HTTP/1.1
Accept: application/jsonCheck it from the shell too: curl -s "$ISSUER/oauth/jwks" | jq '.keys[] | {kid, kty, alg, has_private_part: has("d")}'. Every key shows has_private_part: false.
Why it matters: anyone can fetch these keys to verify the tenant's tokens; a thief who copies them gains nothing.
Passkeys are key pairs. Open DevTools > Network and sign in as Ava with her passkey at
$ISSUER/login. The tenant sends achallenge; the browser returnsauthenticatorData,clientDataJSON(which contains that challenge and the tenant's origin) and asignature.
Why it matters: this is step 6 with the private key held by the authenticator. The tenant stores only Ava's public key, so a breach of its database reveals nothing that could sign in as her.
Bearer behavior. Sign in as Ava through
$ISSUER/token-decoderand copy her access token. In a different terminal or on another machine, call UserInfo with it:
read -rs TOKEN
curl -s -H "Authorization: Bearer $TOKEN" "$ISSUER/oidc/userinfo" | jq .sub
Expected: Ava's sub.
Why it matters: a bearer token works for whoever presents it, unlike the challenge-signed proofs above. Binding a token to a key (G14) changes that.
Whose key is it?
curl -s "$ISSUER/.well-known/openid-configuration" | jq -r .jwks_uri.
Why it matters: verifiers trust these public keys because they fetched them over HTTPS from the issuer they were configured with, not because a message pointed to them. Certificates, in the next labs, explain why that HTTPS connection can be trusted.
Planned walkthrough
These steps need client authentication with a private key (G8, private_key_jwt) and per-client key sets (G56), plus DPoP (G14).
On
lab-print-orders, registerclinic.pubas a JWK, switch the authentication method toprivate_key_jwt, and delete the shared secret.The toolkit builds and signs a client assertion with your own
clinic.key, as your own client, with a uniquejtiand a shortexp, and requests a token:
curl -s -d grant_type=client_credentials -d scope=prints.create \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode "client_assertion=$ASSERTION" "$ISSUER/oauth/token" | jq '{token_type, scope}'
Send the same assertion again. Expected:
401withinvalid_client, because itsjtiwas already used. No secret travelled, and a recorded assertion is useless.With DPoP, request a token bound to the key, then present it from another terminal without a fresh proof. Expected: refused, unlike step 9.
Break it
Send the secret in the request body instead of the header:
curl -s -d grant_type=client_credentials -d client_id="$ORDERS_ID" --data-urlencode "client_secret=$ORDERS_SECRET" "$ISSUER/oauth/token" | jq
Expected: 401 with invalid_client, and Audit shows reason unsupported_auth_method. The verifier, not the client, decides how clients must prove themselves.
Generate a second key pair and verify
req.sigwith its public key. Expected:Verification failure.
Check your work
Press Check my progress. It looks for:
oauth.tokenrejected with reasonunsupported_auth_methodforlab-print-orders(the identifier alone).oauth.tokensucceeded forlab-print-orders.oidc.userinfosucceeded with reasonuserinfo_served(the copied bearer token).oauth.tokenrejected with reasonunsupported_auth_methodforlab-print-orders(the secret in the body).
Your terminal should also show Verified OK and Verification failure from the local steps.
Cleanup
Keep
clinic.keyandclinic.pubonly if you want them for the Storing and using keys lab, which compares them with the tenant's keys. Otherwise delete the folder:cd ~ && rm -r ~/btl-keys-lab.Run
unset LAB_KEY TOKEN.
Missing infrastructure
G8 Client authentication beyond
client_secret_basic. The tenant cannot acceptprivate_key_jwt, so a client cannot prove possession of a private key at the token endpoint. Once it exists, the planned walkthrough replaces the shared secret with a signed assertion.G14 DPoP. Tokens cannot be bound to a key, so step 9 cannot be contrasted with a token that needs a fresh proof from the client's key.
G56 Client key sets. A client cannot register
clinic.pub(or a JWKS URL) for the tenant to verify its assertions against.