Home › The Stash

Keycloak single sign-on lab: one login, two apps, one logout

LabNovember 5, 2026

IntermediatePartly testedTime ~45 minTool KeycloakNeeds Lab 00Cost freeExam Sec+ 4

0 / 12 steps

The goal

Run Keycloak, a free identity provider, on your own machine. You will create a user and register two small web apps with it, then log in once and walk straight into both apps: that is single sign-on. You will read the ID token Keycloak hands the apps, log out of one app and watch the other one log out too, and switch on multi-factor authentication for your user. Along the way: what OIDC, OAuth and SAML each do, and what federation means.

Everything stays inside your own machine. Keycloak and both apps run in your own Ubuntu VM or WSL and listen only on localhost, so nothing on your network can reach them. The user, passwords and client secrets are throwaway lab values; never reuse them anywhere real. Dev mode (used here) keeps its data in a local file and is not safe for real use. Step 12 deletes all of it.

Why this lab. At work you log in once in the morning and email, chat, the HR site and the ticket system all just open. That is single sign-on (SSO), and behind it is an identity provider like Keycloak, Microsoft Entra ID or Okta. Security+ expects you to know SSO, federation, SAML and OAuth by name and by job. Seeing one login unlock two apps, and one logout close both, makes the words stick.

Single sign-on: one password to rule them all. Which is great, right up until it’s the only one the attacker needs. Hence step 11. — Nyx

Words you’ll meet

Term Meaning
IdP Identity provider. The one place that checks who you are (Keycloak here).
SP / client / relying party An app that trusts the IdP instead of keeping its own passwords. Keycloak calls them clients.
Realm Keycloak’s word for a separate space with its own users, apps and rules. Like one company’s login system.
OAuth 2.0 Open Authorization. A way to give an app permission to do something (an access token). On its own it does not say who you are.
OIDC OpenID Connect. A layer on top of OAuth 2.0 that adds who you are: an ID token. This lab uses it.
SAML Security Assertion Markup Language. An older SSO standard that passes signed XML “assertions” through the browser. Common in big companies.
JWT JSON Web Token. Three base64 chunks joined by dots: header, claims, signature. Both OIDC tokens here are JWTs.
Claim One fact inside a token, such as your username or when the token expires.
MFA Multi-factor authentication. Something you know (password) plus something you have (a code from your phone).

Before you start. Use the Ubuntu VM from Lab 00 or Ubuntu 24.04 in WSL2. Keycloak needs about 600 MB of RAM and a few minutes to download (about 175 MB). Windows: run the commands in WSL and open the http://localhost:... links in your normal Windows browser; WSL2 passes localhost through. Mac: do the lab in the Lab 00 VM and use the browser inside the VM. Open a terminal and get a root shell with sudo -i (prompt #).

Steps

01Install Java and the Python bits

Keycloak is a Java program and needs Java 21. The two test apps use Flask (a tiny Python web framework), PyJWT (reads and checks tokens) and requests.

# apt update
# apt install -y openjdk-21-jre-headless python3-flask python3-jwt python3-requests curl jq
# java -version 2>&1 | head -1
...
openjdk version "21.0.12.1" 2026-08-18

02Download Keycloak and check it

Download the release from Keycloak’s official GitHub page, then compare its fingerprint with the one Keycloak publishes next to it.

# mkdir -p ~/kclab && cd ~/kclab
# curl -LO https://github.com/keycloak/keycloak/releases/download/26.8.1/keycloak-26.8.1.tar.gz
# curl -sL https://github.com/keycloak/keycloak/releases/download/26.8.1/keycloak-26.8.1.tar.gz.sha1; echo; sha1sum keycloak-26.8.1.tar.gz
# tar xzf keycloak-26.8.1.tar.gz && cd keycloak-26.8.1
f55701155746ed88e12b3e670d5fa03589e3013e
f55701155746ed88e12b3e670d5fa03589e3013e  keycloak-26.8.1.tar.gz

Why check? The two lines must match. A matching hash proves the file you got is the file Keycloak published, not a damaged or swapped copy. It is the same idea as hashing evidence in incident response. If a newer Keycloak is out, the steps are the same; change the version number in all three places.

03Start Keycloak

Set the first admin’s name and password, cap Java’s memory so it plays nicely on a small machine, and start Keycloak in dev mode on port 8080. The until line waits (about a minute the first time) until it answers.

# export KC_BOOTSTRAP_ADMIN_USERNAME=admin KC_BOOTSTRAP_ADMIN_PASSWORD=lab-admin-pass JAVA_OPTS_APPEND=-Xmx512m
# bin/kc.sh start-dev --http-port 8080 > ~/kclab/keycloak.log 2>&1 &
# until curl -sf http://localhost:8080/realms/master > /dev/null; do sleep 3; done; echo ready
# grep -oE 'Keycloak [0-9.]+ on JVM .* started in [0-9.]+s' ~/kclab/keycloak.log
ready
Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 30.649s

Have a look. Open http://localhost:8080/admin in your browser and sign in as admin / lab-admin-pass to see Keycloak’s admin console. Everything the next steps do with commands, you could also click there. The master realm is only for running Keycloak itself; your users and apps go in a realm of their own.

04Make a realm and a user

kcadm.sh is Keycloak’s admin command line. Sign it in as the admin, create a realm called sglab, create the user alice, and give her a password. The last command asks the new realm to describe itself.

# bin/kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password lab-admin-pass
# bin/kcadm.sh create realms -s realm=sglab -s enabled=true
# bin/kcadm.sh create users -r sglab -s username=alice -s enabled=true -s firstName=Alice -s lastName=Lab -s email=alice@example.com -s emailVerified=true
# bin/kcadm.sh set-password -r sglab --username alice --new-password Lab-pass-2026
# curl -s http://localhost:8080/realms/sglab/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, end_session_endpoint}'
Logging into http://localhost:8080 as user admin of realm master
Created new realm with id 'sglab'
Created new user with id '68c7b294-145d-4751-99ad-9c8244d921ac'
{
  "issuer": "http://localhost:8080/realms/sglab",
  "authorization_endpoint": "http://localhost:8080/realms/sglab/protocol/openid-connect/auth",
  "token_endpoint": "http://localhost:8080/realms/sglab/protocol/openid-connect/token",
  "end_session_endpoint": "http://localhost:8080/realms/sglab/protocol/openid-connect/logout"
}

Reading it. That last answer is the realm’s discovery document: the addresses any OIDC app needs. The issuer is the realm’s official name; every token it signs says who issued it. Apps send people to the authorization endpoint to log in, swap a one-time code for tokens at the token endpoint, and send people to the end session endpoint to log out. example.com is a domain reserved for examples, so no real person gets mail.

05Register two apps

Each app must be registered (a client) before Keycloak will log anyone into it. The loop creates app-one on port 5001 and app-two on port 5002, each with its own secret, the only web addresses it may send people back to, and an address where Keycloak can tell it about logouts.

# for n in one:5001 two:5002; do app=${n%:*}; port=${n#*:}; bin/kcadm.sh create clients -r sglab -s clientId=app-$app -s secret=app-$app-secret -s "redirectUris=[\"http://localhost:$port/*\"]" -s "attributes={\"post.logout.redirect.uris\":\"http://localhost:$port/*\",\"backchannel.logout.url\":\"http://127.0.0.1:$port/backchannel-logout\"}"; done
# bin/kcadm.sh get clients -r sglab --fields clientId,publicClient,standardFlowEnabled --format csv | grep app-
"app-one",false,true
"app-two",false,true

Reading it. publicClient false means each app proves who it is with its secret when it collects tokens, because it runs on a server that can keep a secret. standardFlowEnabled true turns on the normal browser login (the “authorization code flow”). The redirect URIs list matters for security: Keycloak will only send a login result back to those addresses, so a fake site cannot ask for alice’s login to be delivered to it.

06Write the app and start two copies

One small Python file plays both apps; the first word after app.py picks which one. Copy the whole block: it writes app.py, starts app one and app two, and checks both answer.

# cd ~/kclab
# cat > app.py <<'EOF'
# app.py - a tiny OIDC web app for the StashGrid Keycloak SSO lab.
# Run two copies:  python3 app.py one 5001   and   python3 app.py two 5002
import json, secrets, sys, urllib.parse
import jwt, requests
from flask import Flask, redirect, request, session

NAME, PORT = sys.argv[1], int(sys.argv[2])
CLIENT_ID, SECRET = f"app-{NAME}", f"app-{NAME}-secret"
ISSUER = "http://localhost:8080/realms/sglab"
OIDC = f"{ISSUER}/protocol/openid-connect"
HERE = f"http://localhost:{PORT}"
jwks = jwt.PyJWKClient(f"{OIDC}/certs")
logged_out_sids = set()  # Keycloak tells us about logouts here

app = Flask(__name__)
app.secret_key = secrets.token_hex(16)
app.config["SESSION_COOKIE_NAME"] = f"sg_{CLIENT_ID}"  # one cookie per app

def check(token, **extra):  # verify signature, issuer and audience
    key = jwks.get_signing_key_from_jwt(token).key
    return jwt.decode(token, key, algorithms=["RS256"], audience=CLIENT_ID, issuer=ISSUER, **extra)

@app.route("/")
def home():
    c = session.get("claims")
    if c and c.get("sid") in logged_out_sids:
        session.clear(); c = None
    if not c:
        return f'<h1>App {NAME}</h1><p>Not logged in.</p><a href="/login">Log in</a>'
    return (f'<h1>App {NAME}</h1><p>Hello {c["preferred_username"]}, you are logged in.</p>'
            f'<a href="/logout">Log out</a><h2>ID token claims</h2><pre>{json.dumps(c, indent=2)}</pre>')

@app.route("/login")
def login():
    session["state"], session["nonce"] = secrets.token_urlsafe(16), secrets.token_urlsafe(16)
    q = {"client_id": CLIENT_ID, "response_type": "code", "scope": "openid profile email",
         "redirect_uri": f"{HERE}/callback", "state": session["state"], "nonce": session["nonce"]}
    return redirect(f"{OIDC}/auth?" + urllib.parse.urlencode(q))

@app.route("/callback")
def callback():
    if request.args.get("state") != session.pop("state", None):
        return "State mismatch, login refused.", 400
    tok = requests.post(f"{OIDC}/token", auth=(CLIENT_ID, SECRET), data={
        "grant_type": "authorization_code", "code": request.args["code"],
        "redirect_uri": f"{HERE}/callback"}).json()
    claims = check(tok["id_token"])
    if claims.get("nonce") != session.pop("nonce", None):
        return "Nonce mismatch, login refused.", 400
    session["claims"], session["id_token"] = claims, tok["id_token"]
    return redirect("/")

@app.route("/logout")
def logout():
    hint = session.get("id_token", "")
    session.clear()
    q = {"id_token_hint": hint, "post_logout_redirect_uri": f"{HERE}/"}
    return redirect(f"{OIDC}/logout?" + urllib.parse.urlencode(q))

@app.route("/backchannel-logout", methods=["POST"])
def backchannel_logout():
    claims = check(request.form["logout_token"], options={"require": ["sid"]})
    logged_out_sids.add(claims["sid"])
    print(f"app-{NAME}: Keycloak says session {claims['sid'][:8]}... logged out", flush=True)
    return "", 200

app.run(host="127.0.0.1", port=PORT)
EOF
# python3 app.py one 5001 > app-one.log 2>&1 &
# python3 app.py two 5002 > app-two.log 2>&1 &
# until curl -s http://localhost:5002/ > /dev/null; do sleep 1; done; curl -s http://localhost:5001/; echo; curl -s http://localhost:5002/; echo
<h1>App one</h1><p>Not logged in.</p><a href="/login">Log in</a>
<h1>App two</h1><p>Not logged in.</p><a href="/login">Log in</a>

What the app does. /login sends you to Keycloak with a random state and nonce (one-time values that stop someone replaying or swapping a login). Keycloak sends you back to /callback with a one-time code. The app swaps that code, plus its secret, for tokens, then checks the ID token’s signature against Keycloak’s public keys and checks who issued it and who it is for. Never skip that check: an unchecked token is just text anyone could type. /backchannel-logout is where Keycloak tells the app, server to server, that a login session has ended. Each app uses its own cookie name because browsers share cookies between ports on the same host.

07Log in once, use both apps

This part happens in your web browser. Use one normal browser window (not two different browsers).

  1. Open http://localhost:5001 and click Log in.
  2. Keycloak’s “Sign in to sglab” page appears. Type alice and Lab-pass-2026, then Sign In.
  3. You land back on App one: “Hello alice, you are logged in”, with the ID token claims underneath.
  4. Now open http://localhost:5002 and click Log in.
  5. The page flickers and you are on App two, already logged in. No password was asked for.

What just happened. When you signed in, Keycloak gave your browser its own session cookie. App two sent you to Keycloak too, but Keycloak saw that cookie, knew it was alice, and sent you straight back with a fresh code for app two. That is the whole trick behind single sign-on: the apps never see your password, they trust the identity provider, and the identity provider remembers you.

08See the one session from Keycloak’s side

Back in the terminal, ask Keycloak for alice’s login sessions.

# cd ~/kclab/keycloak-26.8.1
# USER_ID=$(bin/kcadm.sh get users -r sglab -q username=alice --fields id --format csv --noquotes); echo $USER_ID
# bin/kcadm.sh get users/$USER_ID/sessions -r sglab | jq '.[] | {username, ipAddress, clients}'
68c7b294-145d-4751-99ad-9c8244d921ac
{
  "username": "alice",
  "ipAddress": "127.0.0.1",
  "clients": {
    "09730fe3-8ef2-458f-a773-bd1835e246fa": "app-one",
    "e0ba130f-c149-48e1-af18-70d825746600": "app-two"
  }
}

Reading it. One session for alice, with two apps hanging off it. That one session is what single sign-on means, and it is also why one logout can end everything (step 10). Admins use this same list to kick a user out of everything at once when an account is stolen.

09Read the ID token

Look at the claims under “ID token claims” on App two in your browser. Ours looked like this (your IDs, times and random values will differ):

App two, in the browser
{
  "acr": "0",
  "at_hash": "_J4IJPiJBN4WTv9C8ox5SA",
  "aud": "app-two",
  "auth_time": 1793845905,
  "azp": "app-two",
  "email": "alice@example.com",
  "email_verified": true,
  "exp": 1793846205,
  "family_name": "Lab",
  "given_name": "Alice",
  "iat": 1793845905,
  "iss": "http://localhost:8080/realms/sglab",
  "jti": "1b3b2373-37cb-9a1b-7a5c-6b3179af9ec0",
  "name": "Alice Lab",
  "nonce": "_XmhaYQLvlQ1aKz6p8e5pQ",
  "preferred_username": "alice",
  "sid": "3pgvFvELgsBkVY6fTyyfr1g1",
  "sub": "68c7b294-145d-4751-99ad-9c8244d921ac",
  "typ": "ID"
}
Claim What it says
iss Issuer: which identity provider and realm signed this. The app refuses tokens from anyone else.
sub Subject: alice’s permanent ID in this realm. Apps should key on this, not on the username, which can change.
aud, azp Audience and authorized party: which app this token is for. App one’s token says app-one, so app two would reject it.
iat, exp Issued at and expires, in Unix time (seconds since 1 Jan 1970). Five minutes apart here: short-lived on purpose.
auth_time When alice actually typed her password. It is the same in both apps’ tokens, because she only did it once.
sid Session ID at Keycloak. Same value in both apps: proof they share one login session.
nonce The one-time value app two made in /login, echoed back so the app knows this token answers its request.
acr Authentication context: how strongly she logged in. 0 means she was let in by an existing session (SSO) rather than by typing a password just now.
preferred_username, name, email Profile facts, included because the app asked for the profile and email scopes.

10Log out once

In the browser, go to App one (http://localhost:5001) and click Log out. You land back on App one: “Not logged in.” Now reload App two (http://localhost:5002): it says “Not logged in” too, and its Log in link asks for a password again. Then check from the terminal:

# grep -h Keycloak ~/kclab/app-*.log
# bin/kcadm.sh get users/$USER_ID/sessions -r sglab | jq length
app-one: Keycloak says session 3pgvFvEL... logged out
app-two: Keycloak says session 3pgvFvEL... logged out
0

Reading it. Logging out of app one sent you to Keycloak’s end session endpoint. Keycloak ended alice’s one session, then called each app directly (the “back channel”, server to server, not through your browser) with a signed logout token naming that session. Both apps logged it, and app two forgot alice. Zero sessions are left. Without back-channel logout, app two would have stayed logged in on its own cookie until that ran out: a classic gap when people assume “I logged out” means “I logged out everywhere”.

11Turn on MFA for alice

Make alice set up a one-time code app (TOTP, time-based one-time password) at her next login.

# bin/kcadm.sh update users/$USER_ID -r sglab -s 'requiredActions=["CONFIGURE_TOTP"]'

Log in to App one again in the browser. After the password, Keycloak shows Mobile Authenticator Setup with a QR code. If you like, scan it with an authenticator app (Google Authenticator, Microsoft Authenticator, FreeOTP), type the six-digit code, and from now on alice needs both. To switch it back off for the lab:

# bin/kcadm.sh update users/$USER_ID -r sglab -s 'requiredActions=[]'
# bin/kcadm.sh get users/$USER_ID -r sglab --fields username,requiredActions
{
  "username" : "alice",
  "requiredActions" : [ ]
}

For everyone, not just alice. In the admin console, Authentication → Required actions lets you make “Configure OTP” a default for every new user, and under Authentication → Flows → browser, the conditional one-time-code (2FA) step can be changed to Required so nobody gets in on a password alone. Keycloak also supports passkeys (WebAuthn) there. This is where SSO and MFA belong together: one login protects many apps, so that one login must be strong.

12Clean up

Stop both apps and Keycloak, then delete the lab folder (Keycloak’s dev database lives inside it, so the realm, user and clients go too).

# pkill -f 'app.py (one|two)'
# pkill -f 'kclab/keycloak-26.8.1'
# sleep 5; cd ~ && rm -rf ~/kclab
# exit

Java and the Python packages stay installed. To remove Java too: sudo apt remove openjdk-21-jre-headless.

OIDC, OAuth and SAML: who does what

Standard Answers Carries You meet it
OAuth 2.0 “May this app do X for you?” (authorization) Access tokens “Allow this app to read your calendar”; app-to-app APIs
OpenID Connect “Who is this person?” (authentication) on top of OAuth ID tokens (JWT, JSON) “Sign in with Google/Microsoft”, modern web and mobile apps, this lab
SAML 2.0 “Who is this person, and what are they allowed?” (authentication, plus attributes) Signed XML assertions, passed through the browser Company SSO into older business apps (HR, payroll, many SaaS tools)

SAML does the same job as OIDC, just older and in XML: the app (service provider) sends you to the identity provider, which hands your browser a signed assertion to post back to the app. Keycloak speaks both: when you create a client you choose OpenID Connect or SAML. An exam tip: if the question says XML or assertion, think SAML; if it says token, JSON or mobile, think OAuth/OIDC; if it is about granting an app permission rather than proving who, it is OAuth.

Federation means trusting an identity provider run by someone else. Keycloak can do it under Identity providers: add Google, Microsoft, GitHub, or another company’s SAML or OIDC server, and those people log in with their accounts while your apps still only trust Keycloak. Your company and a partner trusting each other’s logins is federation; “Sign in with Google” on a shopping site is federation too.

What you learned

  • SSO — one session at the identity provider, many apps; the apps never see the password
  • Clients and redirect URIs — apps must be registered, and logins only go back to approved addresses
  • ID token claims — iss, sub, aud, exp, sid and nonce, and why the app must check the signature
  • Single logout — ending the one session and telling every app over the back channel
  • MFA — a required action for one user, or a required step in the login flow for everyone
  • OAuth vs OIDC vs SAML, and federation — permission vs identity, JSON vs XML, and trusting someone else’s IdP

Tried it? Tell us

Nova built this lab from Steven’s ideas, and we ran every command once before publishing (the browser steps by script). If a step broke, behaved differently for you, or you know a better way, tell us. Ideas for the next lab are welcome too — no promises on timing.

NextSecurity+ practice test: 50 questions→

Go deeper

Original walkthrough. Every command on this page was run end to end, exactly as shown, on Ubuntu 24.04 (WSL2, kernel 6.6.87.2, Keycloak 26.8.1, OpenJDK 21.0.12, Python 3.12.3, Flask 3.0.2, PyJWT 2.7.0) on 4 Nov 2026; outputs are from that run. Partly tested: the browser steps (7, 10, and the login in 11) were played by a test script that makes the same web requests a browser does with one shared cookie jar, and every result matched what this page describes; we did not click through them in a real browser or scan the QR code with a phone. Your IDs, times and random values will differ. StashGrid isn’t affiliated with CompTIA or the Keycloak project. If something doesn’t match what your machine does, tell me and I will fix it.