01Lab Metadata
| Field | Value |
|---|---|
| Lab ID | IB-SIA-11 |
| Track | Identity Bytes, Senior IAM Architect Track |
| Phase | Phase 2, Token Engineering |
| Difficulty | Intermediate |
| Estimated Time | 3.5 to 4.5 hours |
| Core Technologies | OAuth 2.0 Token Exchange (RFC 8693), Keycloak 24.x token-exchange feature, Python 3.11, Flask 3.0, requests 2.32, curl, jq |
| Builds On | Lab 02 (realm, clients and client scopes), Lab 06 (HA cluster hosting the token endpoint), Lab 09 (reading the act and scope claims in the exchanged tokens) |
| Feeds Into | Lab 12 (session management), Lab 16 (externalised authorization, which enforces the downscoped audience), Lab 27 (SPIFFE/SPIRE workload identity) |
02Lab Title and Description
Token Exchange (RFC 8693)
Northgate Financial's HR portal does not work alone. When Amina Smith opens her payslip, the portal calls a downstream payroll service, which in turn calls a document service to render the PDF. Today, the portal forwards Amina's original access token down that whole chain, unchanged. Every service in the path receives a token carrying the full set of scopes Amina was granted at login, including scopes those downstream services have no business seeing. If the document service is compromised, the attacker holds a token that can do everything Amina can do, not merely render a document.
This is the confused deputy problem, and OAuth 2.0 Token Exchange, defined in RFC 8693, is the standard answer. Token exchange lets a client present one token to the authorization server and receive a different token in return: narrower in scope, bound to a specific downstream audience, and carrying an act (actor) claim that records who is acting on whose behalf. The result is a delegation chain that is explicit and auditable rather than a single over-privileged token passed around by trust.
In this lab you configure Keycloak's token exchange capability, then build a two-hop service chain: the HR portal exchanges Amina's token for a downscoped token addressed only to the payroll service, and the payroll service exchanges that in turn for a further token addressed only to the document service. You will read the act claim at each hop to see the delegation recorded, and you will prove that a downscoped token is rejected when presented to a service it was not addressed to. You will also draw the important distinction between impersonation, where the downstream token looks like it came directly from the user, and delegation, where the acting party is explicitly recorded.
Estimated completion time: 3.5 to 4.5 hours, including token exchange configuration and multi-hop chain testing.
03Prerequisites
Completed Prior Labs
| Lab | Why it is required |
|---|---|
| IB-SIA-02, Keycloak realm and OIDC federation | Provides the northgate realm, the hr-portal client, and the client scope mechanism this lab uses to define narrow downstream scopes. |
| IB-SIA-06, Keycloak High Availability | The token exchange requests are sent to the token endpoint through the ib-lb load balancer, consistent with every other token operation in Phase 2. |
| IB-SIA-09, JWT, JWS and JWE Deep Dive | You decode the exchanged tokens to inspect their scope, aud and act claims. The decoding and verification skills from Lab 09 are used directly. |
System Requirements
| Resource | Minimum |
|---|---|
| OS | Ubuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2 |
| RAM | 7 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this) |
| Disk | 3 GB free |
| CPU | 2 cores sufficient |
| Network | Access to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI and Docker Hub |
Required Tools
| Tool | Exact Version |
|---|---|
| Docker Engine | 25.0 or later |
| Python | 3.11.x |
| requests | 2.32.3 |
| Flask | 3.0.3 |
| curl | 8.x |
| jq | 1.7 |
Install and verify: Ubuntu/Debian
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip jq curl
mkdir -p ~/ib-labs/ib-token-exchange
cd ~/ib-labs/ib-token-exchange
python3.11 -m venv .venv
source .venv/bin/activate
pip install requests==2.32.3 flask==3.0.3
python3.11 --version # Expect: Python 3.11.x
python3.11 -c "import requests, flask; print('libs ok')"
jq --version # Expect: jq-1.7 or later
Install and verify: macOS
brew install python@3.11 jq
mkdir -p ~/ib-labs/ib-token-exchange && cd ~/ib-labs/ib-token-exchange
python3.11 -m venv .venv && source .venv/bin/activate
pip install requests==2.32.3 flask==3.0.3
python3.11 -c "import requests, flask; print('libs ok')"
jq --version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04
# Then follow the Ubuntu/Debian instructions above.
--features=token-exchange, and its behaviour has changed between versions, with a fully RFC 8693 aligned "standard" token exchange stabilising in later releases. Confirm exactly what your running version supports before relying on any specific claim mapping, and treat this as an area where a Senior IAM Architect states version-specific uncertainty rather than assuming behaviour carries across upgrades.
04Real World Problem Statement
Passing a single user token down an entire service chain concentrates risk: every service in the path holds the full authority of the user, whether it needs it or not. Token exchange replaces that broad, implicit trust with narrow, explicit, auditable delegation at each hop.
Risk
A compromised downstream service holding a forwarded full-scope user token can act with the user's complete authority. Northgate's document rendering service should never be able to initiate a payment, yet a forwarded token would let a compromised version attempt exactly that.
Compliance
Least privilege is a core control in ISO 27001 Annex A and a recurring theme in FCA operational resilience expectations. A token that grants far more than the receiving service needs is a least-privilege violation that an auditor can point to directly.
Productivity
An explicit, standardised delegation chain means engineers can reason about exactly what authority each service holds by reading its token, rather than tracing which upstream service happened to forward what. Incident response is faster when the act chain is right there in the token.
Security Posture
Audience-bound tokens mean a token addressed to the payroll service is rejected outright by the document service. The blast radius of a leaked intermediate token is confined to the single service it was addressed to.
Concrete scenario: Amina Smith (asmith) opens her payslip. The HR portal must call the payroll service, which must call the document service. Rather than forwarding Amina's login token, which carries scopes for profile, payroll and administrative functions, the portal exchanges it for a token scoped only to payroll:read and addressed only to the payroll service. The payroll service exchanges that for a token scoped only to document:render addressed only to the document service. In this lab you build that exact chain and prove that the final token cannot be turned back around to call the payroll service.
05Skills Mapped to Production Solutions
| Skill Learned | Real-World Enterprise Application |
|---|---|
Constructing an RFC 8693 token exchange request with subject_token, requested_token_type, audience and scope | Implementing secure service-to-service delegation in microservice and API gateway architectures |
| Downscoping a token so each hop receives only the authority it needs | Enforcing least privilege across a call chain, a common finding area in financial services security reviews |
Reading and reasoning about the act (actor) claim and the delegation chain it records | Building auditable delegation for compliance and incident response, and detecting unexpected actors in a chain |
| Distinguishing delegation from impersonation and choosing correctly | Designing service authority models where accountability (who really acted) must be preserved or, deliberately, hidden |
| Binding tokens to a specific audience and rejecting mis-addressed tokens | Confining the blast radius of a leaked intermediate token, directly relevant to zero-trust service mesh design |
06Architecture Overview
Component Breakdown
| Component | Purpose | Technology | Deployment | Ports | Key Configuration |
|---|---|---|---|---|---|
| Keycloak token endpoint | Performs the token exchange, issuing downscoped audience-bound tokens with an act claim | Keycloak 24.x, token-exchange feature | Existing HA cluster from Lab 06, via ib-lb | 8443 | token-exchange feature enabled; fine-grained admin permissions granting exchange rights between clients |
| hr-portal client | Holds the user's login token and initiates the first exchange, addressed to the payroll service | Keycloak confidential client | Existing client from Lab 02, given a client secret for this lab | N/A | Permission to exchange to the payroll-api audience |
| Payroll service | Validates its audience-bound token, then performs the second exchange addressed to the document service | Python 3.11, Flask 3.0 | Docker container ib-payroll-api on ib-lab-net | 8096 | Validates aud = payroll-api and scope = payroll:read |
| Document service | Validates its audience-bound token and renders the payslip document | Python 3.11, Flask 3.0 | Docker container ib-document-api on ib-lab-net | 8097 | Validates aud = document-api and scope = document:render |
Data Flow
- The HR portal holds Amina's full-scope login token and calls the token endpoint with the token-exchange grant type, requesting a token whose audience is the payroll service and whose scope is narrowed to
payroll:read.
Why: the portal deliberately gives away authority at this step, requesting less than it holds, so that the token it forwards cannot do more than read payroll data. - Keycloak issues a downscoped token with
substill set to Amina and anactclaim naming the HR portal as the acting party.
Why: the resource owner remains Amina throughout, which is correct: the action is still on her behalf. Theactclaim records that the portal, not Amina directly, presented the token. - The payroll service validates its token, then performs a second exchange presenting its own token as the subject and requesting a token for the document service scoped to
document:render.
Why: each service downscopes again for the next hop, so authority only ever narrows along the chain, never widens. - Keycloak issues the document-service token with a nested
actclaim recording both the payroll service and, nested within it, the HR portal.
Why: the full delegation history travels with the token, so the document service, and any auditor later, can see the complete chain of who acted on whose behalf.
Security Considerations
| Concern | Lab Approach |
|---|---|
| Audience binding | Each exchanged token carries an aud naming exactly one downstream service; each service rejects any token whose audience is not itself. |
| Scope narrowing | Every exchange requests a strict subset of the presenting token's scope; the lab verifies that authority never widens along the chain. |
| Exchange authorisation | Keycloak's fine-grained permissions restrict which clients may exchange tokens to which audiences, so an arbitrary client cannot mint a token for a service it has no relationship with. |
| Accountability | Delegation (with a recorded act chain) is used rather than impersonation, so the acting parties remain visible for audit. |
07Step by Step Implementation
Phase 1: Enable Token Exchange and Define the Downstream Scopes
Step 1.1: Confirm the token-exchange feature is enabled
Check the feature, and enable it if needed
# Check whether token-exchange is active on the running server.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
serverinfo -r master | jq '.features[] | select(.name | test("token.exchange"))'
# If it is not enabled, add the feature to both nodes' start command and
# rebuild, using the same rolling restart pattern as Lab 08. For a Docker
# Compose start, this means adding to the command:
# start --features=token-exchange,admin-fine-grained-authz
# then, per node:
# docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh build
# docker restart ib-keycloak-1
# (wait for health) then repeat for ib-keycloak-2
token-exchange, with an enabled status. If the query returns nothing, the feature is not active and no token exchange request will succeed regardless of client configuration; enable it and rebuild before continuing.
admin-fine-grained-authz is enabled alongside token exchange because Keycloak controls which client may exchange to which target using fine-grained admin permissions. Without it, you cannot grant the scoped exchange permissions in Step 1.3.
Step 1.2: Create the downstream clients and their narrow scopes
Create the two downstream clients
for svc in payroll-api document-api; do
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create clients \
-r northgate \
-s clientId=$svc \
-s enabled=true \
-s protocol=openid-connect \
-s bearerOnly=true
done
Create client scopes payroll:read and document:render
# Create the two optional client scopes.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create client-scopes \
-r northgate -s name=payroll:read -s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true'
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create client-scopes \
-r northgate -s name=document:render -s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true'
Add an audience mapper so each scope stamps the correct aud
# The audience mapper ensures a token carrying payroll:read is addressed to
# payroll-api, and likewise for document:render. Substitute the scope id
# returned when you list client-scopes.
SCOPE_ID=$(docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
client-scopes -r northgate --fields id,name | jq -r '.[] | select(.name=="payroll:read") | .id')
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
client-scopes/$SCOPE_ID/protocol-mappers/models -r northgate \
-s name=payroll-aud \
-s protocol=openid-connect \
-s protocolMapper=oidc-audience-mapper \
-s 'config."included.client.audience"=payroll-api' \
-s 'config."access.token.claim"=true'
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get clients?clientId=payroll-api -r northgate | jq '.[0].bearerOnly' and confirm it returns true. List the client scopes and confirm both payroll:read and document:render exist. If the audience mapper create fails, confirm you substituted a real scope id into SCOPE_ID.
Step 1.3: Grant the scoped exchange permissions
Enable permissions on the target clients (Admin Console)
# Fine-grained exchange permissions are most reliably configured in the
# Admin Console for the target (audience) client:
# Clients, payroll-api, Permissions tab, toggle "Permissions enabled".
# This creates a "token-exchange" scoped permission. Edit that permission
# and attach a client policy whose clients list contains hr-portal.
# Repeat for document-api, attaching a policy whose clients list contains
# payroll-api.
#
# The exact resource and policy names differ across Keycloak versions, which
# is why the Console, which surfaces the current names, is used here rather
# than a version-fragile kcadm script.
echo "Configure exchange permissions in the Admin Console as described above."
token-exchange permission exists with a client policy referencing hr-portal. A quick end-to-end confirmation comes in Step 2.2, when the first exchange either succeeds or returns an authorization error; a 403 there almost always traces back to a missing or misconfigured permission at this step.
Phase 2: Perform the First Exchange (HR Portal to Payroll)
Step 2.1: Obtain Amina's full-scope login token
Get the user token (direct grant for lab convenience)
# Direct grant is used only to obtain a subject token without a browser step.
USER_TOKEN=$(curl -sk -X POST \
"https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
-d "client_id=hr-portal" \
-d "client_secret=REPLACE_WITH_HR_PORTAL_SECRET" \
-d "grant_type=password" \
-d "username=asmith" \
-d "password=REPLACE_WITH_ASMITH_PASSWORD" \
-d "scope=openid profile" | jq -r '.access_token')
echo "$USER_TOKEN" | cut -c1-40; echo "..."
USER_TOKEN is a non-empty three-part JWT. Decode it as in Lab 09 and note the scope claim; this is the broad scope you will narrow in the next step. If the token is empty, confirm hr-portal has a client secret set and the direct grant enabled for this lab.
Step 2.2: Exchange for a payroll-scoped token
The token exchange request
PAYROLL_TOKEN=$(curl -sk -X POST \
"https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
-d "client_id=hr-portal" \
-d "client_secret=REPLACE_WITH_HR_PORTAL_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$USER_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "audience=payroll-api" \
-d "scope=payroll:read" | jq -r '.access_token')
# Decode and inspect the exchanged token's key claims.
python3.11 - <<PYEOF
import base64, json, os
t = "$PAYROLL_TOKEN".split('.')[1]
t += '=' * (-len(t) % 4)
claims = json.loads(base64.urlsafe_b64decode(t))
print("sub: ", claims.get("sub"))
print("aud: ", claims.get("aud"))
print("scope:", claims.get("scope"))
print("act: ", json.dumps(claims.get("act")))
PYEOF
aud containing payroll-api, a scope narrowed to include payroll:read and not the broader payroll or admin scopes from the original, and an act claim naming hr-portal as the acting party. If you receive a 403 or an access_denied error, return to Step 1.3: the exchange permission from hr-portal to payroll-api is the usual cause.
Phase 3: Chain the Second Exchange and Enforce Audience
Step 3.1: Build the payroll and document services
service.py, a validating resource service (used by both, configured by env)
# service.py
# A minimal audience-validating service. SERVICE_AUD and SERVICE_SCOPE set
# what this instance accepts. The payroll instance additionally knows how to
# exchange onward to the document service.
import os
import json
import base64
from flask import Flask, request, jsonify
import requests
from jwcrypto import jwt, jwk
app = Flask(__name__)
ISSUER = "https://ib-lb:8443/realms/northgate"
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"
SERVICE_AUD = os.environ["SERVICE_AUD"]
SERVICE_SCOPE = os.environ["SERVICE_SCOPE"]
requests.packages.urllib3.disable_warnings()
_jwks = jwk.JWKSet.from_json(requests.get(JWKS_URL, verify=False).text)
def _validate(token):
claims = json.loads(jwt.JWT(jwt=token, key=_jwks, algs=["RS256"]).claims)
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if SERVICE_AUD not in aud_list:
raise ValueError(f"token audience {aud} does not include {SERVICE_AUD}")
if SERVICE_SCOPE not in (claims.get("scope") or "").split():
raise ValueError(f"token scope missing {SERVICE_SCOPE}")
return claims
@app.get("/whoami")
def whoami():
auth = request.headers.get("Authorization", "")
_, _, token = auth.partition(" ")
try:
claims = _validate(token)
except Exception as e:
return jsonify({"error": str(e)}), 401
return jsonify({
"service_audience": SERVICE_AUD,
"resource_owner_sub": claims.get("sub"),
"act_chain": claims.get("act"),
"scope": claims.get("scope"),
})
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8096"))
app.run(host="0.0.0.0", port=port, ssl_context="adhoc")
Run both services in their own terminals
# Terminal A, payroll service
export SERVICE_AUD=payroll-api SERVICE_SCOPE=payroll:read PORT=8096
python3.11 service.py
# Terminal B, document service
export SERVICE_AUD=document-api SERVICE_SCOPE=document:render PORT=8097
python3.11 service.py
curl -sk -H "Authorization: Bearer $PAYROLL_TOKEN" https://localhost:8096/whoami | jq. Expect a 200 showing resource_owner_sub as Amina's subject and an act_chain naming hr-portal. If you get a 401 audience error, the audience mapper from Step 1.2 did not stamp payroll-api into the token.
Step 3.2: Perform the second exchange, payroll to document
The chained exchange
# The payroll service presents ITS token as the subject of a new exchange.
# In a real service this call is made from within the payroll service using
# its own client credentials; shown here as a script for clarity.
DOC_TOKEN=$(curl -sk -X POST \
"https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
-d "client_id=payroll-api" \
-d "client_secret=REPLACE_WITH_PAYROLL_API_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$PAYROLL_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "audience=document-api" \
-d "scope=document:render" | jq -r '.access_token')
curl -sk -H "Authorization: Bearer $DOC_TOKEN" \
https://localhost:8097/whoami | jq
act_chain in the response: the resource owner (sub) is still Amina, and the act claim now nests the payroll service with the HR portal inside it, recording the full two-hop delegation. This nesting is the entire point: the chain of who acted on whose behalf travels with the token.
payroll-api was made a confidential client capable of authenticating for this exchange. A bearer-only client cannot initiate an exchange because it has no credentials of its own; in a real deployment the payroll service would use a confidential client identity to perform the onward exchange.
Step 3.3: Prove audience binding stops a mis-addressed token
Present the document token to the wrong service
# The document-addressed token presented to the payroll service must fail.
curl -sk -o /dev/null -w "Document token to payroll service: HTTP %{http_code}\n" \
-H "Authorization: Bearer $DOC_TOKEN" \
https://localhost:8096/whoami
# And the payroll-addressed token to the document service must also fail.
curl -sk -o /dev/null -w "Payroll token to document service: HTTP %{http_code}\n" \
-H "Authorization: Bearer $PAYROLL_TOKEN" \
https://localhost:8097/whoami
document-api as its audience, so the payroll service rejects it, and vice versa. This confirms that a leaked intermediate token is confined to the single service it was addressed to, rather than being usable anywhere in the chain.
act claim recorded, and then nested, exactly who was acting on her behalf. The closing test is the one that matters: a token addressed to one service is inert at any other, so a leak at any point in the chain cannot spread sideways. That is least privilege and auditable delegation, expressed in the token itself rather than assumed by convention.
08Testing and Validation
End-to-End Test Scenarios
| Scenario | Steps | Expected Result |
|---|---|---|
| First exchange downscopes | Compare the scope of the payroll token against the original user token | Payroll token scope is a strict subset; broad scopes are gone |
| Audience binding at hop one | Call the payroll service with the payroll token | HTTP 200 with act chain naming hr-portal |
| Chained exchange | Exchange the payroll token for a document token and call the document service | HTTP 200 with a nested act chain (payroll then hr-portal) |
| Resource owner preserved | Inspect sub at every hop | Remains Amina throughout, never a service account |
Negative Tests
| Test | Expected Result |
|---|---|
| Present the document token to the payroll service | HTTP 401, audience mismatch |
Request a broader scope in the exchange than the subject token holds (for example scope=admin) | Exchange either drops the unauthorised scope or is rejected; the returned token must not contain a scope the subject did not have |
| Attempt the first exchange after removing the hr-portal to payroll-api permission | HTTP 403 or access_denied from the token endpoint |
Present a payroll token with no payroll:read scope to the payroll service | HTTP 401, scope missing |
Common Failure Modes
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Every exchange returns 403 or access_denied | Fine-grained exchange permission not configured, or the token-exchange feature not enabled | Re-verify Step 1.1 (feature) and Step 1.3 (permission) |
Exchanged token has no aud for the target service | The audience mapper on the client scope was not created or the scope was not applied | Re-check the audience mapper in Step 1.2 and that the requested scope is assigned to the target client |
Exchanged token has no act claim | Version-specific: some Keycloak configurations perform impersonation-style exchange without recording an actor | Confirm your version and configuration produce a delegation (act) token; consult the version's token exchange documentation |
| Second exchange fails while the first succeeds | payroll-api is bearer-only and cannot authenticate to initiate an exchange | Give payroll-api a confidential client identity with a secret, as noted in Step 3.2 |
09Security Analysis
What Makes This Implementation Secure
- Each hop receives a token scoped only to what it needs and addressed only to itself, so a compromised service cannot act beyond its narrow remit.
- Audience binding confines a leaked intermediate token to a single service; it is rejected everywhere else in the chain.
- Delegation is used rather than impersonation, so the
actclaim preserves the full accountability chain for audit and incident response. - Keycloak's fine-grained permissions constrain which client may exchange to which audience, so token minting rights are explicit and reviewable rather than implicit.
What Is Intentionally Simplified for the Lab
- The direct grant is used to obtain the initial subject token quickly; production uses the authorization code with PKCE flow from Lab 04.
- TLS certificate verification is disabled against
ib-lband the local services because they present self-signed certificates. - The onward exchange from the payroll service is shown as a script for clarity; in production the service performs it internally using its own confidential client credentials, which are held in a secrets manager.
- The services validate audience and scope but do not additionally enforce sender constraint; combining token exchange with the DPoP binding from Lab 10 is the stronger production posture.
Production Hardening Recommendations
| Area | Recommendation |
|---|---|
| Exchange permissions | Keep the client-to-audience exchange permissions as narrow as possible and review them like firewall rules; never grant a client the ability to exchange to audiences it does not call |
| Delegation over impersonation | Prefer delegation with a recorded act chain; reserve impersonation for the rare cases where the downstream must not know a service acted, and document why |
| Combine with sender constraint | Apply the DPoP binding from Lab 10 to exchanged tokens so a leaked downscoped token is also inert without its key |
| Token lifetime | Give exchanged tokens short lifetimes matched to the downstream call, so a leaked intermediate token expires quickly |
| Version tracking | Track Keycloak's token exchange feature status across upgrades; the standard, RFC 8693 aligned behaviour has changed between releases and should be re-validated after any upgrade |
10Cleanup
Stop the services and clear local tokens
cd ~/ib-labs/ib-token-exchange
# Stop both service terminals with Ctrl+C, then clear any saved token vars by
# closing the shell or unsetting them.
unset USER_TOKEN PAYROLL_TOKEN DOC_TOKEN 2>/dev/null || true
deactivate 2>/dev/null || true
Remove the lab clients and scopes (optional, full teardown only)
# Keep these if you intend to reuse the service chain in Lab 16.
for svc in payroll-api document-api; do
CID=$(docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
clients?clientId=$svc -r northgate --fields id | jq -r '.[0].id')
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh delete \
clients/$CID -r northgate
done
# Disable the direct grant re-enabled on hr-portal for Step 2.1, if applicable.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
clients/HR_PORTAL_UUID -r northgate -s directAccessGrantsEnabled=false
payroll-api and document-api no longer appear in the client list. The Keycloak HA cluster, OpenLDAP and PostgreSQL are otherwise unchanged and ready for Lab 12.
11Recommended Learning Links
- RFC 8693, OAuth 2.0 Token Exchange, IETF
- RFC 8693 Section 4.1, the act (actor) claim and delegation semantics, IETF
- Keycloak Server Administration Guide, Token Exchange section, Keycloak documentation
- Keycloak release notes covering standard token exchange, Keycloak documentation
- OAuth 2.0 Security Best Current Practice, IETF, sections on token handling across services
- NIST SP 800-204 series, security strategies for microservices, NIST
12Portfolio Publishing Guide
Sanitise Before Publishing
This lab handles real tokens and multiple client secrets. Confirm none reach your repository.
Sanitisation checklist commands
grep -R "REPLACE_WITH" . --include="*.py" --include="*.sh" --include="*.md" || echo "Clean"
cat >> .gitignore <<'EOF'
*.token
*_token.txt
.env
.venv/
EOF
git status
README for the Repository
README.md skeleton
# IB-SIA-11: Token Exchange (RFC 8693)
A two-hop service chain demonstrating OAuth 2.0 Token Exchange against
Keycloak: a broad user token is downscoped and audience-bound at each hop,
with the delegation recorded in a nested act claim, and mis-addressed
tokens are rejected.
## Stack
Python 3.11, Flask 3.0, Keycloak 24.x (token-exchange feature)
## What it demonstrates
- Downscoping a user token to a single downstream service
- Audience binding that confines a leaked token to one service
- The nested act claim recording a multi-hop delegation chain
- Delegation versus impersonation
## Part of the Identity Bytes Senior IAM Architect Track
Lab 11 of 36. See identity-bytes.com for the full curriculum.
Git Commands
Commit and push
git add service.py README.md .gitignore
git commit -m "IB-SIA-11: RFC 8693 token exchange with downscoping and act-claim delegation"
git push origin main
Track Index Line
Add the following line to your master portfolio index:
IB-SIA-11 | Token Exchange (RFC 8693) | Intermediate | Delegation, downscoping, audience binding, act claim
LinkedIn Draft
Most microservice breaches are not one service getting compromised. They are one service getting compromised and holding a token that could do everything.
When a user opens a payslip, that click can fan out across three or four internal services. The lazy pattern, and the common one, is to take the user's login token and forward it down the whole chain. Every service in the path now holds the user's full authority. The document renderer, which should only ever draw a PDF, is holding a token that could, in principle, move money.
This week I implemented the standard fix: OAuth 2.0 Token Exchange (RFC 8693). Instead of forwarding one broad token, each service exchanges it for a narrower one, scoped to exactly what the next hop needs and addressed only to that hop. A token minted for the payroll service is rejected outright by the document service, so a leak anywhere in the chain cannot spread sideways.
The part I find genuinely elegant is the act claim. The user stays the resource owner the whole way down, but the token records, and nests, exactly who acted on whose behalf at each hop. Delegation you can audit, rather than trust you have to assume.
In your architecture, how many services are holding more authority than they actually use, purely because a token got forwarded?