IB-SIA-12 Advanced Est. 4 to 5 hours
Identity Bytes // Senior IAM Architect Track

Session Management

Rotate refresh tokens with reuse detection so a stolen token revokes the whole session, propagate logout to every relying party with OIDC Back-Channel Logout, and understand exactly where session state lives across the Lab 06 Infinispan cluster.

01Lab Metadata

FieldValue
Lab IDIB-SIA-12
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 2, Token Engineering
DifficultyAdvanced
Estimated Time4 to 5 hours
Core TechnologiesOIDC Session Management, OIDC Back-Channel Logout, refresh token rotation, Keycloak 24.x, Infinispan clustering, Python 3.11, Flask 3.0, curl, jq
Builds OnLab 02 (realm and OIDC clients), Lab 04 (authorization code flow producing the session), Lab 06 (HA cluster and its Infinispan session caches), Lab 09 (reading the logout token JWT)
Feeds IntoLab 33 (identity incident response, where forced session termination is a containment action), Lab 35 (identity observability, which monitors session lifecycle events)

02Lab Title and Description

Session Management

Tokens are short-lived by design, but the session behind them is not. When Amina Smith logs into Northgate Financial's HR portal, Keycloak creates a user session that outlives any single access token: the portal quietly refreshes its access token in the background using a long-lived refresh token, and the session persists across several applications she uses through single sign-on. This session is where the real security questions of Phase 2 come to rest. What happens when a refresh token is stolen? What happens when Amina, or an administrator, logs her out? And in the two-node cluster you built in Lab 06, where does the session actually live, and what happens to it when a node fails?

This lab answers all three. First you enable refresh token rotation with reuse detection: each use of a refresh token issues a new one and invalidates the old, so that if an attacker steals a refresh token and uses it, the legitimate client's next refresh, or the attacker's second use, is detected as a reuse and the entire session is revoked. Second you configure OIDC Back-Channel Logout so that when a session ends, Keycloak actively notifies every relying party through a signed logout token, rather than leaving stale sessions alive in downstream applications. Third you examine how user session state is held in the Infinispan caches across ib-keycloak-1 and ib-keycloak-2, and confirm that a session created on one node survives the failure of the other.

Getting session management wrong is how "I logged out" becomes "but the other app still had me signed in", and how a stolen refresh token becomes indefinite persistence. This is advanced platform work that a Senior IAM Architect owns end to end.

Estimated completion time: 4 to 5 hours, including rotation configuration, back-channel logout setup, and HA session failover testing.

03Prerequisites

Completed Prior Labs

LabWhy it is required
IB-SIA-02, Keycloak realm and OIDC federationProvides the northgate realm and the hr-portal client whose sessions and refresh tokens this lab manages.
IB-SIA-04, Authorization code with PKCEThe authorization code flow is what establishes the user session and issues the initial refresh token that this lab rotates.
IB-SIA-06, Keycloak High AvailabilityThe Infinispan session caches that hold user and client sessions were configured in Lab 06. This lab inspects and stress-tests that session state under node failure.
IB-SIA-09, JWT, JWS and JWE Deep DiveThe OIDC back-channel logout token is a signed JWT with specific claims. You decode and validate it using the skills from Lab 09.

System Requirements

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM8 GB free (the Keycloak HA cluster from Lab 06 accounts for most of this)
Disk3 GB free
CPU4 cores recommended for running both nodes plus the relying party
NetworkAccess to the running Keycloak cluster on ib-lab-net; outbound HTTPS to PyPI

Required Tools

ToolExact Version
Docker Engine25.0 or later
Python3.11.x
Flask3.0.3
requests2.32.3
jwcrypto1.5.6 (to validate the logout token)
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-session
cd ~/ib-labs/ib-session
python3.11 -m venv .venv
source .venv/bin/activate
pip install flask==3.0.3 requests==2.32.3 jwcrypto==1.5.6

python3.11 --version    # Expect: Python 3.11.x
python3.11 -c "import flask, requests, jwcrypto; 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-session && cd ~/ib-labs/ib-session
python3.11 -m venv .venv && source .venv/bin/activate
pip install flask==3.0.3 requests==2.32.3 jwcrypto==1.5.6

python3.11 -c "import flask, requests, jwcrypto; 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.
INFO Keycloak's handling of session persistence has evolved: recent versions moved towards persisting user sessions in the database by default (the persistent user sessions capability) rather than holding them only in memory. Where this lab describes the Infinispan cache behaviour, confirm against your exact version whether sessions are memory-only, database-backed, or both, because failover behaviour depends on it.

04Real World Problem Statement

A session is a standing grant of access that persists long after any individual token expires. If Northgate cannot detect a stolen refresh token, cannot reliably end a session everywhere at once, and cannot keep sessions alive through a node failure, then its single sign-on becomes a single point of both compromise and outage.

Risk

A stolen refresh token, without rotation, is a long-lived key to Amina's account that renews itself indefinitely. Rotation with reuse detection turns a stolen refresh token from a persistent foothold into a trap: using it trips the reuse detection and revokes the session.

Compliance

The ability to terminate a user's access promptly and completely is required by ISO 27001 Annex A access controls and by FCA operational resilience expectations. A logout that leaves a downstream application still signed in is an incomplete revocation an auditor will note.

Productivity

Reliable single logout means Northgate staff on shared workstations can end their session with confidence that every connected application is also signed out, rather than manually logging out of each one and hoping.

Security Posture

Sessions that survive a node failure mean a routine restart or a node loss does not force thousands of staff to re-authenticate at once, which both protects availability and avoids the support load and credential-fatigue that a mass re-login event causes.

Concrete scenario: During Northgate's last incident review, an analyst asked a simple question no one could answer confidently: if we believe jpatel's refresh token has been stolen, can we be sure that revoking his session actually signs him out of every application, and that the thief's copy of the refresh token stops working? In this lab you build the mechanisms that let you answer yes to both: rotation with reuse detection, and back-channel logout that reaches every relying party.

05Skills Mapped to Production Solutions

Skill LearnedReal-World Enterprise Application
Configuring refresh token rotation with a maximum reuse count and reuse detectionHardening single sign-on against refresh token theft, a standard requirement in financial services security baselines
Implementing an OIDC Back-Channel Logout endpoint that validates the logout tokenBuilding relying parties that participate correctly in single logout across an enterprise application estate
Distinguishing online sessions, offline sessions and client sessionsReasoning about which sessions a logout or revocation actually terminates, a frequent source of production confusion
Inspecting and reasoning about session state in Infinispan caches across cluster nodesDiagnosing session loss, unexpected re-authentication and failover behaviour in clustered identity platforms
Forcing administrative session termination as an incident containment actionExecuting a rapid, complete access cut-off during incident response, directly feeding Lab 33
Testing session survival through node failureValidating that an HA identity platform meets its availability objectives under real fault conditions

06Architecture Overview

asmith single sign-on session ib-lb HAProxy ib-keycloak-1 Infinispan session caches sessions, clientSessions, offlineSessions distributed with owners=2 ib-keycloak-2 replica of session entries survives peer failure (owners=2) ib-postgres realm + persistent sessions RELYING PARTY ib-rp (Flask) /backchannel-logout validates logout token, kills its local session REFRESH ROTATION + REUSE DETECTION refresh #1 to RT-A returns RT-B, RT-A now invalid refresh #2 to RT-B returns RT-C, RT-B now invalid reuse of RT-A (thief's copy) = revoke whole session a stolen token becomes a tripwire, not a foothold BACK-CHANNEL LOGOUT session ends (user, admin, or reuse revocation) Keycloak POSTs a signed logout_token JWT to each RP's backchannel endpoint RP validates and terminates its local session logout_token

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
Keycloak realm session settingsEnforces refresh token rotation, reuse detection and session lifetimesKeycloak 24.x realm configurationExisting HA cluster from Lab 068443revokeRefreshToken=true, refreshTokenMaxReuse=0, session idle and max lifespans
Infinispan session cachesHold user sessions, client sessions and offline sessions across nodesInfinispan distributed cacheib-keycloak-1 and ib-keycloak-2 from Lab 06internal cluster transportCache owners of 2 so each entry exists on both nodes
Relying partyA demo application that registers a back-channel logout endpoint and terminates its local session on receipt of a valid logout tokenPython 3.11, Flask 3.0, jwcryptoDocker container ib-rp on ib-lab-net8098Registered Backchannel Logout URL on its Keycloak client
PostgreSQLStores realm data and, in versions with persistent user sessions, the sessions themselvesPostgreSQL 16Existing ib-postgres from Lab 065432 (internal)Unchanged from Lab 06

Data Flow

  1. A client refreshes its access token by presenting a refresh token. With rotation enabled, Keycloak returns a new refresh token and marks the presented one as consumed.
    Why: a refresh token that is valid only once collapses the window in which a stolen copy is useful, and creates the conditions for reuse detection.
  2. If a consumed refresh token is presented again, Keycloak treats it as a reuse and revokes the entire user session.
    Why: a legitimate client never reuses an old refresh token; a second presentation almost always means two parties hold the token, which is exactly the theft scenario, so revoking is the safe response.
  3. When a session ends, for any reason, Keycloak sends a signed logout token to each relying party's registered back-channel logout endpoint. The relying party validates the token's signature and claims, then destroys its local session.
    Why: access tokens already issued cannot be un-issued, but the relying party can stop honouring its own session, so single logout is only complete when every relying party is actively told.
  4. Session state is written to the Infinispan caches with two owners, so an entry created while a request was served by one node also exists on the other.
    Why: if a node fails, the surviving node already holds the session, so the user is not forced to re-authenticate by the loss of a single node.

Security Considerations

ConcernLab Approach
Refresh token theftRotation with a maximum reuse of zero means any second use of a refresh token is detected and revokes the session.
Logout token authenticityThe relying party validates the logout token's signature against the realm JWKS and checks the required events and sid/sub claims before acting.
Session fixation and lifetimeIdle and maximum session lifetimes bound how long any session, legitimate or otherwise, can persist.
Availability under failureTwo cache owners keep sessions alive through a single node loss, so security-driven restarts do not become an availability incident.

07Step by Step Implementation

Phase 1: Refresh Token Rotation and Reuse Detection

Step 1.1: Enable rotation and reuse detection on the realm

Purpose: make each refresh token single-use and revoke on reuse Context: applies to all clients in the northgate realm
Set the realm session and refresh settings
# revokeRefreshToken=true turns on rotation; refreshTokenMaxReuse=0 means
# zero permitted reuses, so any second use of a consumed refresh token
# revokes the session. Lifetimes are set to modest lab values.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/northgate \
  -s revokeRefreshToken=true \
  -s refreshTokenMaxReuse=0 \
  -s ssoSessionIdleTimeout=1800 \
  -s ssoSessionMaxLifespan=36000
VERIFICATION Confirm the settings took effect: docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get realms/northgate --fields revokeRefreshToken,refreshTokenMaxReuse,ssoSessionIdleTimeout | jq should show revokeRefreshToken: true and refreshTokenMaxReuse: 0. Because the realm lives in shared PostgreSQL, this change is immediately effective on both nodes.

Step 1.2: Obtain a session and rotate normally

Purpose: confirm the happy path, each refresh returns a new refresh token Context: establishes the baseline before the theft test
Get an initial token pair and rotate once
# Direct grant used for lab convenience to establish a session.
RESP=$(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")

RT_A=$(echo "$RESP" | jq -r '.refresh_token')

# First refresh: present RT_A, receive RT_B.
RESP2=$(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=refresh_token" \
  -d "refresh_token=$RT_A")

RT_B=$(echo "$RESP2" | jq -r '.refresh_token')
echo "RT_A and RT_B differ: $([ "$RT_A" != "$RT_B" ] && echo yes || echo no)"
VERIFICATION The script should print RT_A and RT_B differ: yes, confirming rotation is active: the refresh token changed after use. If they are identical, rotation is not enabled; re-check Step 1.1.

Step 1.3: Simulate the theft, reuse the old token, watch the session die

Purpose: prove reuse detection revokes the whole session Context: RT_A is the "stolen" copy; presenting it after RT_B was issued is the reuse
Reuse the consumed token and confirm revocation
# The attacker presents the OLD refresh token RT_A, which was already
# consumed when RT_B was issued. This is the reuse.
echo "=== Attacker reuses consumed RT_A ==="
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=refresh_token" \
  -d "refresh_token=$RT_A" | jq '{error, error_description}'

# Now the LEGITIMATE client tries to use its current, valid RT_B. Because the
# reuse revoked the whole session, even the good token no longer works.
echo "=== Legitimate client tries its current RT_B afterwards ==="
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=refresh_token" \
  -d "refresh_token=$RT_B" | jq '{error, error_description}'
VERIFICATION The reuse of RT_A returns an error (typically invalid_grant). Critically, the subsequent attempt with the legitimate RT_B also fails, because detecting the reuse revoked the entire session, not merely the reused token. This is the intended behaviour: once two parties are seen holding refresh tokens for one session, the safe action is to end the session and force a fresh, interactive login.
SECURITY WARNING Reuse detection deliberately punishes the legitimate user along with the thief: everyone is signed out and must log in again. That is the correct trade. The alternative, letting the session continue, means silently tolerating the possibility that an attacker holds a working refresh token. A forced re-login is a small price for closing that door.
PRODUCTION CONSIDERATION A non-zero refreshTokenMaxReuse is sometimes set to tolerate benign races, for example a client that retries a refresh after a network timeout and accidentally sends the same token twice. Setting it above zero widens the theft window, so the value must be chosen deliberately, balancing client robustness against security, and documented as a conscious decision.

Phase 2: OIDC Back-Channel Logout

Step 2.1: Build a relying party with a back-channel logout endpoint

Purpose: a demo app that can be told, out of band, that a session has ended Context: Keycloak POSTs a signed logout token to this endpoint
rp.py, the relying party with logout validation
# rp.py
# A minimal relying party. It holds a set of "active local sessions" keyed by
# the Keycloak session id (sid). When Keycloak POSTs a back-channel logout
# token, the RP validates it and removes the matching local session.

import json
import time
import requests
from flask import Flask, request, jsonify
from jwcrypto import jwt, jwk

app = Flask(__name__)
ISSUER = "https://ib-lb:8443/realms/northgate"
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"
CLIENT_ID = "hr-portal"
requests.packages.urllib3.disable_warnings()
_jwks = jwk.JWKSet.from_json(requests.get(JWKS_URL, verify=False).text)

# Demo local session store: sid -> username. Populated at login in a real RP;
# seeded here via /debug/seed for the lab.
LOCAL_SESSIONS = {}


@app.post("/debug/seed")
def seed():
    body = request.get_json(force=True)
    LOCAL_SESSIONS[body["sid"]] = body["username"]
    return jsonify({"active_sessions": LOCAL_SESSIONS})


@app.post("/backchannel-logout")
def backchannel_logout():
    logout_token = request.form.get("logout_token", "")
    try:
        verified = jwt.JWT(jwt=logout_token, key=_jwks, algs=["RS256"])
        claims = json.loads(verified.claims)
    except Exception as e:
        return jsonify({"error": f"invalid logout token: {e}"}), 400

    # Required checks per the OIDC Back-Channel Logout specification.
    if claims.get("iss") != ISSUER:
        return jsonify({"error": "issuer mismatch"}), 400
    aud = claims.get("aud")
    aud_list = aud if isinstance(aud, list) else [aud]
    if CLIENT_ID not in aud_list:
        return jsonify({"error": "audience mismatch"}), 400
    events = claims.get("events", {})
    if "http://schemas.openid.net/event/backchannel-logout" not in events:
        return jsonify({"error": "not a back-channel logout event"}), 400
    # A logout token must NOT contain a nonce, and must contain sub or sid.
    if "nonce" in claims:
        return jsonify({"error": "logout token must not contain nonce"}), 400

    sid = claims.get("sid")
    removed = LOCAL_SESSIONS.pop(sid, None) if sid else None
    return jsonify({"terminated_sid": sid, "was_active": removed is not None,
                    "remaining": LOCAL_SESSIONS}), 200


@app.get("/sessions")
def sessions():
    return jsonify(LOCAL_SESSIONS)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8098, ssl_context="adhoc")
Run the relying party
export FLASK_ENV=development
python3.11 rp.py   # listens on https://localhost:8098
VERIFICATION Confirm the RP is up: curl -sk https://localhost:8098/sessions | jq returns an empty object. If it fails to start, confirm the JWKS fetch succeeded, which requires the Keycloak cluster to be reachable at ib-lb:8443.

Step 2.2: Register the back-channel logout URL on the client

Purpose: tell Keycloak where to send logout tokens for hr-portal Context: enables session-required logout tokens carrying the sid
Set the logout URL and session-required flag
# Replace HR_PORTAL_UUID with the hr-portal client UUID from Lab 02.
# From the host, ib-rp is reachable on the Docker network; use the container
# name if the RP runs as a container, or host.docker.internal if it runs on
# the host and Keycloak runs in Docker. Adjust to your setup.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
  clients/HR_PORTAL_UUID -r northgate \
  -s 'attributes."backchannel.logout.url"=https://ib-rp:8098/backchannel-logout' \
  -s 'attributes."backchannel.logout.session.required"=true'
VERIFICATION Confirm the attributes: docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get clients/HR_PORTAL_UUID -r northgate | jq '.attributes | {url: ."backchannel.logout.url", sess: ."backchannel.logout.session.required"}' should show your URL and "true". If the URL is unreachable from Keycloak's network at logout time, the logout token delivery will fail silently from the user's perspective; Section 8 covers diagnosing this.

Step 2.3: Trigger a logout and observe the relying party session end

Purpose: prove logout propagates to the relying party, not only to Keycloak Context: uses admin session termination to trigger the back-channel notification
Seed a local RP session, then force logout and observe
# Establish a Keycloak session and capture its sid from the ID token.
RESP=$(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")
ID_TOKEN=$(echo "$RESP" | jq -r '.id_token')
SID=$(echo "$ID_TOKEN" | cut -d. -f2 | tr '_-' '/+' | \
  awk '{l=length($0)%4; if(l>0)for(i=0;i<4-l;i++)$0=$0"="; print}' | \
  base64 -d 2>/dev/null | jq -r '.sid')

# Mirror that session into the RP's local store.
curl -sk -X POST https://localhost:8098/debug/seed \
  -H "Content-Type: application/json" \
  -d "{\"sid\":\"$SID\",\"username\":\"asmith\"}" | jq

# Now force-logout the user's sessions as an administrator. This is also the
# exact containment action used in Lab 33.
USER_ID=$(docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
  users -r northgate -q username=asmith --fields id | jq -r '.[0].id')
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
  users/$USER_ID/logout -r northgate

# The RP should have received a logout token and dropped the session.
sleep 2
curl -sk https://localhost:8098/sessions | jq
VERIFICATION After the forced logout, /sessions on the RP should no longer contain the seeded sid, proving Keycloak actively delivered a valid logout token and the RP terminated its local session. If the session is still present, check that Keycloak can reach the RP's URL and that the RP's validation of iss, aud and events matched the token; the RP logs will show which check failed.
INFO Back-channel logout uses a server-to-server POST carrying a signed logout_token, so it works even when the user's browser is closed. This is its advantage over front-channel logout, which relies on loading logout URLs in the user's browser and fails if the browser is gone.

Phase 3: Session State Across the HA Cluster

Step 3.1: Inspect the session caches on both nodes

Purpose: see where session state lives in the cluster Context: builds directly on the Infinispan setup from Lab 06
Query active sessions and cache ownership
# Count active sessions for the hr-portal client via the Admin REST API.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
  clients/HR_PORTAL_UUID/session-count -r northgate

# Confirm the cluster sees both nodes and the session caches are distributed.
# The exact cache names are 'sessions', 'clientSessions', 'offlineSessions'
# and 'offlineClientSessions'. Cache metrics are exposed on the management
# port if enabled in Lab 06.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get \
  serverinfo -r master | jq '.systemInfo.serverTime, .memoryInfo.total'
VERIFICATION The session-count query returns a non-zero count while at least one session is active. If you enabled Infinispan metrics in Lab 06, confirm the sessions cache reports entries on both nodes, which is the evidence that session state is replicated rather than pinned to one node.

Step 3.2: Prove a session survives a node failure

Purpose: confirm the HA promise, losing one node does not sign users out Context: this is the availability payoff of the Lab 06 architecture
Establish a session, kill a node, verify the session persists
# Establish a fresh session and keep its refresh token.
RESP=$(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=jpatel" \
  -d "password=REPLACE_WITH_JPATEL_PASSWORD" -d "scope=openid profile")
RT=$(echo "$RESP" | jq -r '.refresh_token')

# Simulate the failure of one node.
docker stop ib-keycloak-1

# The load balancer routes the next refresh to ib-keycloak-2, which must
# already hold the session because the caches have two owners.
sleep 3
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=refresh_token" -d "refresh_token=$RT" \
  | jq '{ok: (.access_token != null), error}'

# Restore the node for later labs.
docker start ib-keycloak-1
VERIFICATION The refresh after stopping ib-keycloak-1 should return ok: true, proving the session created earlier survived on ib-keycloak-2. If it returns an error indicating the session is unknown, the session caches are not configured with two owners, or persistent sessions are not enabled; revisit the Infinispan configuration from Lab 06 before relying on failover.
SECURITY WARNING Remember to run docker start ib-keycloak-1 afterwards. Leaving the cluster running on a single node removes the redundancy every subsequent lab assumes, and a second node loss would then take the whole identity platform down.
What just happened? You took control of the session that lives behind the tokens. Rotation with reuse detection turned a stolen refresh token from a self-renewing foothold into a tripwire: the moment it is reused, the whole session is revoked and everyone re-authenticates. Back-channel logout closed the "I logged out but the other app still had me" gap, actively delivering a signed logout token to the relying party so single logout is genuinely single. And you proved the Lab 06 cluster keeps its promise: a session created on one node survived the loss of the other, so a security restart is not an availability incident. Together these are the difference between a login system and a managed identity platform.

08Testing and Validation

End-to-End Test Scenarios

ScenarioStepsExpected Result
Normal rotationRefresh once and compare tokensA new refresh token is returned; the old one differs
Reuse revocationReuse a consumed refresh token, then try the current oneBoth fail; the session was revoked on reuse detection
Back-channel logoutSeed an RP session, force admin logoutThe RP receives a valid logout token and drops the session
Node failure survivalEstablish a session, stop one node, refreshThe refresh succeeds via the surviving node

Negative Tests

TestExpected Result
POST a logout token with a wrong issuer to the RPHTTP 400, issuer mismatch, no session terminated
POST a logout token whose signature does not verifyHTTP 400, invalid logout token
POST a token containing a nonce claim to the RP logout endpointHTTP 400; a logout token must not contain a nonce
Refresh after the SSO idle timeout has elapsedinvalid_grant; the session expired through inactivity

Common Failure Modes

SymptomLikely CauseResolution
Refresh token does not change on refreshrevokeRefreshToken is falseRe-apply Step 1.1 and confirm with the realm fields query
Reuse does not revoke the sessionrefreshTokenMaxReuse is greater than zeroSet it to 0 for strict single use, then retest
Back-channel logout never reaches the RPKeycloak cannot resolve or reach the RP URL from its own networkConfirm the URL is reachable from inside the Keycloak container, adjusting host naming (ib-rp vs host.docker.internal) to your topology
Session lost when a node stopsSession caches have a single owner, or persistent sessions are off in a version that needs themSet cache owners to 2 and confirm the persistent session behaviour for your version

09Security Analysis

What Makes This Implementation Secure

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
Reuse toleranceKeep refreshTokenMaxReuse at 0 unless a specific client robustness need justifies otherwise, and document any non-zero value as a conscious risk decision
Logout coverageRegister back-channel logout URLs on every relying party, and monitor for delivery failures so a silently unreachable RP does not leave stale sessions
Session lifetimesSet idle and maximum session lifespans to match the sensitivity of the applications; shorter for high-risk, and align offline session lifetimes deliberately
Failover testingInclude node-failure and rolling-restart drills in regular resilience testing, confirming sessions survive and logout still propagates during a failover
MonitoringAlert on spikes in reuse-detection revocations, which can indicate either a token theft campaign or a misbehaving client, feeding Lab 35's observability work

10Cleanup

Stop the relying party and confirm the cluster is whole
cd ~/ib-labs/ib-session
# Stop the RP terminal with Ctrl+C.
# CRITICAL: confirm both Keycloak nodes are running after the Phase 3 test.
docker ps --filter "name=ib-keycloak" --format '{{.Names}}: {{.Status}}'
# If ib-keycloak-1 is not listed as Up, start it:
docker start ib-keycloak-1
deactivate 2>/dev/null || true
Optionally revert the back-channel logout URL and relax lab lifetimes
# Keep the rotation settings, they are a genuine improvement worth retaining.
# Only revert the back-channel URL if you are removing the demo RP entirely.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
  clients/HR_PORTAL_UUID -r northgate \
  -s 'attributes."backchannel.logout.url"='
VERIFICATION Confirm both ib-keycloak-1 and ib-keycloak-2 report Up and healthy, and that the RP process is stopped. Retaining refresh token rotation is recommended; the cluster is otherwise ready for Phase 3 and Lab 13.

11Recommended Learning Links

12Portfolio Publishing Guide

Sanitise Before Publishing

This lab handles refresh tokens and a client secret. Confirm none reach your repository.

Sanitisation checklist commands
grep -R "REPLACE_WITH" . --include="*.py" --include="*.sh" --include="*.md" || echo "Clean"

cat >> .gitignore <<'EOF'
*.token
.env
.venv/
EOF

git status

README for the Repository

README.md skeleton
# IB-SIA-12: Session Management

Demonstrates refresh token rotation with reuse detection, an OIDC
Back-Channel Logout relying party, and session survival across a Keycloak
HA cluster node failure.

## Stack
Python 3.11, Flask 3.0, jwcrypto 1.5, Keycloak 24.x (Infinispan clustering)

## What it demonstrates
- Refresh rotation: a reused token revokes the whole session
- Back-channel logout: a signed logout token terminates a relying party session
- HA: a session survives the loss of one cluster node

## Part of the Identity Bytes Senior IAM Architect Track
Lab 12 of 36. See identity-bytes.com for the full curriculum.

Git Commands

Commit and push
git add rp.py README.md .gitignore
git commit -m "IB-SIA-12: session management, refresh rotation, back-channel logout, HA failover"
git push origin main

Track Index Line

Add the following line to your master portfolio index:

IB-SIA-12 | Session Management | Advanced | Refresh rotation, reuse detection, back-channel logout, HA session failover

LinkedIn Draft

"I logged out." The other application: "Never heard of it, you are still signed in here."

Tokens expire in minutes. The session behind them can last all day, and that session is where the real security questions live. This week I worked through three of them against a clustered Keycloak.

First, refresh token theft. With rotation and reuse detection, a stolen refresh token stops being a self-renewing foothold and becomes a tripwire: the moment it is used a second time, the whole session is revoked and everyone re-authenticates. Yes, that inconveniences the legitimate user too. That is the correct trade, because the alternative is quietly tolerating an attacker who might hold a working token.

Second, single logout that is actually single. OIDC Back-Channel Logout delivers a signed logout token server to server to every relying party, so logging out means logging out everywhere, even if the browser is already closed.

Third, the availability side: I stopped a cluster node mid-session and confirmed the user stayed logged in, because the session already existed on the surviving node. A security restart should never become an outage.

If you asked your platform today, "are we certain revoking this user's session signs them out of every connected app and kills the thief's token," could you answer yes?

Next: IB-SIA-13, RBAC to ABAC
Phase 3, Modern Authorization, begins. With authentication and session handling mastered, the focus shifts from proving who you are to deciding what you may do, starting with the move from role-based to attribute-based access control.