IB-SIA-11 Intermediate Est. 3.5 to 4.5 hours
Identity Bytes // Senior IAM Architect Track

Token Exchange (RFC 8693)

Stop passing the user's full-scope token down every service hop. Exchange it for a narrowly scoped, audience-bound token that records the delegation chain in an act claim, so a downstream service gets exactly the authority it needs and no more.

01Lab Metadata

FieldValue
Lab IDIB-SIA-11
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 2, Token Engineering
DifficultyIntermediate
Estimated Time3.5 to 4.5 hours
Core TechnologiesOAuth 2.0 Token Exchange (RFC 8693), Keycloak 24.x token-exchange feature, Python 3.11, Flask 3.0, requests 2.32, curl, jq
Builds OnLab 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 IntoLab 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

LabWhy it is required
IB-SIA-02, Keycloak realm and OIDC federationProvides 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 AvailabilityThe 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 DiveYou 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

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM7 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this)
Disk3 GB free
CPU2 cores sufficient
NetworkAccess to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI and Docker Hub

Required Tools

ToolExact Version
Docker Engine25.0 or later
Python3.11.x
requests2.32.3
Flask3.0.3
curl8.x
jq1.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.
SECURITY WARNING Keycloak's token exchange has been an evolving feature across releases. In Keycloak 24.x it is a preview feature enabled with --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 LearnedReal-World Enterprise Application
Constructing an RFC 8693 token exchange request with subject_token, requested_token_type, audience and scopeImplementing secure service-to-service delegation in microservice and API gateway architectures
Downscoping a token so each hop receives only the authority it needsEnforcing 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 recordsBuilding auditable delegation for compliance and incident response, and detecting unexpected actors in a chain
Distinguishing delegation from impersonation and choosing correctlyDesigning service authority models where accountability (who really acted) must be preserved or, deliberately, hidden
Binding tokens to a specific audience and rejecting mis-addressed tokensConfining the blast radius of a leaked intermediate token, directly relevant to zero-trust service mesh design

06Architecture Overview

asmith logs into HR portal HR PORTAL hr-portal client full-scope user token exchanges downward PAYROLL SVC ib-payroll-api aud: payroll-api scope: payroll:read DOCUMENT SVC ib-document-api aud: document-api scope: document:render KEYCLOAK TOKEN ENDPOINT (via ib-lb) grant_type = urn:ietf:params:oauth:grant-type:token-exchange issues downscoped, audience-bound token with act claim DELEGATION CHAIN recorded in the act claim Token to payroll: sub = asmith, act = { sub: hr-portal } Token to document: sub = asmith, act = { sub: payroll, act: { sub: hr-portal } } the resource owner stays asmith throughout; the acting parties nest exch 1 exch 2

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
Keycloak token endpointPerforms the token exchange, issuing downscoped audience-bound tokens with an act claimKeycloak 24.x, token-exchange featureExisting HA cluster from Lab 06, via ib-lb8443token-exchange feature enabled; fine-grained admin permissions granting exchange rights between clients
hr-portal clientHolds the user's login token and initiates the first exchange, addressed to the payroll serviceKeycloak confidential clientExisting client from Lab 02, given a client secret for this labN/APermission to exchange to the payroll-api audience
Payroll serviceValidates its audience-bound token, then performs the second exchange addressed to the document servicePython 3.11, Flask 3.0Docker container ib-payroll-api on ib-lab-net8096Validates aud = payroll-api and scope = payroll:read
Document serviceValidates its audience-bound token and renders the payslip documentPython 3.11, Flask 3.0Docker container ib-document-api on ib-lab-net8097Validates aud = document-api and scope = document:render

Data Flow

  1. 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.
  2. Keycloak issues a downscoped token with sub still set to Amina and an act claim 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. The act claim records that the portal, not Amina directly, presented the token.
  3. 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.
  4. Keycloak issues the document-service token with a nested act claim 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

ConcernLab Approach
Audience bindingEach exchanged token carries an aud naming exactly one downstream service; each service rejects any token whose audience is not itself.
Scope narrowingEvery exchange requests a strict subset of the presenting token's scope; the lab verifies that authority never widens along the chain.
Exchange authorisationKeycloak'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.
AccountabilityDelegation (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

Purpose: verify the cluster can perform token exchange at all Context: this is a preview feature and may not be on by default
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
VERIFICATION The serverinfo query should return one or more features whose name contains 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.
INFO 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

Purpose: define payroll-api and document-api as audiences with dedicated scopes Context: the exchanged tokens will be addressed to these audiences
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'
VERIFICATION List the clients with 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

Purpose: authorise hr-portal to exchange to payroll-api, and payroll-api to exchange to document-api Context: without this, exchange requests are rejected even with the feature on
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."
VERIFICATION On the payroll-api client's Permissions tab, confirm "Permissions enabled" is on and a 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.
PRODUCTION CONSIDERATION These exchange permissions are the security boundary of the whole feature. A permission that is too broad, for example allowing any client to exchange to any audience, silently undoes the least-privilege benefit token exchange is meant to provide. Review these permissions with the same care as firewall rules.

Phase 2: Perform the First Exchange (HR Portal to Payroll)

Step 2.1: Obtain Amina's full-scope login token

Purpose: get the subject token that the first exchange downscopes Context: represents the token the HR portal holds after Amina logs in
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 "..."
VERIFICATION Confirm 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

Purpose: turn the broad user token into one narrowed to payroll:read for payroll-api Context: this is the core RFC 8693 request
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
VERIFICATION The decoded token should show 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.
SECURITY WARNING Confirm for yourself that the exchanged token's scope is genuinely narrower than the original, not merely different. Token exchange only delivers its security benefit if each exchange requests a strict subset of the presenting token's authority. A configuration that lets a client request broader scope than it holds would be a privilege escalation path.

Phase 3: Chain the Second Exchange and Enforce Audience

Step 3.1: Build the payroll and document services

Purpose: two small services that validate their audience and perform or consume the next exchange Context: the payroll service both validates its token and exchanges again for the document service
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
VERIFICATION Call the payroll service with the payroll token from Step 2.2: 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

Purpose: narrow further to document:render addressed to document-api Context: produces the nested act claim recording the full chain
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
VERIFICATION The document service returns a 200. Crucially, inspect the 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.
INFO Note that 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

Purpose: confirm the document token cannot be turned around to call payroll Context: this is the blast-radius containment the design promises
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
VERIFICATION Both calls must return HTTP 401. The document token names 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.
What just happened? You replaced a single over-privileged token, forwarded on trust, with a chain of narrow, audience-bound tokens minted on demand. The HR portal gave away authority deliberately, exchanging Amina's broad login token for one scoped only to reading payroll and addressed only to the payroll service. The payroll service did the same again for the document service. At each hop the resource owner stayed Amina while the 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

ScenarioStepsExpected Result
First exchange downscopesCompare the scope of the payroll token against the original user tokenPayroll token scope is a strict subset; broad scopes are gone
Audience binding at hop oneCall the payroll service with the payroll tokenHTTP 200 with act chain naming hr-portal
Chained exchangeExchange the payroll token for a document token and call the document serviceHTTP 200 with a nested act chain (payroll then hr-portal)
Resource owner preservedInspect sub at every hopRemains Amina throughout, never a service account

Negative Tests

TestExpected Result
Present the document token to the payroll serviceHTTP 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 permissionHTTP 403 or access_denied from the token endpoint
Present a payroll token with no payroll:read scope to the payroll serviceHTTP 401, scope missing

Common Failure Modes

SymptomLikely CauseResolution
Every exchange returns 403 or access_deniedFine-grained exchange permission not configured, or the token-exchange feature not enabledRe-verify Step 1.1 (feature) and Step 1.3 (permission)
Exchanged token has no aud for the target serviceThe audience mapper on the client scope was not created or the scope was not appliedRe-check the audience mapper in Step 1.2 and that the requested scope is assigned to the target client
Exchanged token has no act claimVersion-specific: some Keycloak configurations perform impersonation-style exchange without recording an actorConfirm your version and configuration produce a delegation (act) token; consult the version's token exchange documentation
Second exchange fails while the first succeedspayroll-api is bearer-only and cannot authenticate to initiate an exchangeGive payroll-api a confidential client identity with a secret, as noted in Step 3.2

09Security Analysis

What Makes This Implementation Secure

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
Exchange permissionsKeep 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 impersonationPrefer 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 constraintApply the DPoP binding from Lab 10 to exchanged tokens so a leaked downscoped token is also inert without its key
Token lifetimeGive exchanged tokens short lifetimes matched to the downstream call, so a leaked intermediate token expires quickly
Version trackingTrack 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
VERIFICATION Confirm both service processes are stopped and, if you performed the full teardown, that 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

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?

Next: IB-SIA-12, Session Management
Phase 2 closes by tackling the session behind the tokens: refresh token rotation, back-channel logout, and how Keycloak's session state behaves across the HA cluster.