Understanding the OAuth2.0 Protocol using Keycloak as an IdP
Exploring the mechanics behind OAuth2.0 using open-source Identity provider Keycloak
Understanding OAuth 2.0 Using Keycloak as an IdP
A hands-on lab series covering OAuth 2.0 grant types, token mechanics, and real-world patterns using Keycloak as the identity provider.
Stack: Keycloak (Docker), Python/Flask, Node.js/Express, curl, jwt.io Prerequisites: Docker installed, basic terminal familiarity
Before You Start — Core Concepts
Before touching Keycloak, understand what these three things are:
- Realm — An isolated namespace in Keycloak. Think of it as a separate tenant. It has its own users, clients, roles, and settings. The
masterrealm is Keycloak’s own admin realm — never use it for your apps. - Client — Represents an application that requests tokens. A client can be a web app, mobile app, CLI tool, or backend service.
- User — A person (or entity) that can authenticate inside a realm.
OAuth 2.0 is an authorization framework. It defines how an application can get permission to access resources on behalf of a user. It does this by issuing tokens instead of sharing passwords.
Lab 1 — Setup: Run Keycloak and Create Your First Realm, Client, and User
Goal: Get Keycloak running, understand the base data model, and retrieve your first JWT.
Step 1: Run Keycloak via Docker
Open your terminal and run:
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.2.4 start-dev
Wait until you see Keycloak 26.x.x started in the logs. Then open http://localhost:8080 and log in with admin / admin.
Step 2: Create a Realm
- Click the dropdown at the top left that says master
- Click Create realm
- Set Realm name to
oauth-labs - Click Create
You are now inside the oauth-labs realm. Every client, user, and role you create from here is isolated to this realm.
Step 3: Create a Client
- Click Clients in the left sidebar
- Click Create client
- General settings:
- Client type:
OpenID Connect - Client ID:
myclient - Click Next
- Client type:
- Capability config:
- Toggle Client authentication to ON (this makes it a confidential client — it will have a secret)
- Make sure Standard flow and Direct access grants are checked
- Click Next
- Login settings: leave everything blank for now
- Click Save
Why Client authentication ON? A confidential client has a secret. It means only your registered app can request tokens — not just anyone who knows the client ID. Public clients (no secret) are used for SPAs and mobile apps where a secret cannot be safely stored.
Step 4: Get Your Client Secret
- On the
myclientpage, click the Credentials tab - Copy the Client secret value and save it — you will need this in every lab
If you do not see a Credentials tab, go to Settings tab and make sure Client authentication is ON, then save.
Step 5: Create a User
- Click Users in the left sidebar
- Click Create new user
- Set Username to
testuser - Click Create
- Go to the Credentials tab
- Click Set password
- Set password to
password123, toggle Temporary to OFF - Click Save → Save password
Temporary OFF is important. If left ON, Keycloak forces a password reset on first login and will reject token requests until the user logs in interactively.
Step 6: Activate the User Account
Before requesting tokens via curl, the user needs to have logged in at least once. Go to:
http://localhost:8080/realms/oauth-labs/account
Log in with testuser / password123. This activates the account.
Step 7: Get Your First Token
Run this in your terminal, replacing YOUR_SECRET with the value from Step 4:
curl -X POST http://localhost:8080/realms/oauth-labs/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=myclient" \
-d "client_secret=YOUR_SECRET" \
-d "username=testuser" \
-d "password=password123"
You should get back:
{
"access_token": "eyJhbGci...",
"expires_in": 300,
"refresh_token": "eyJhbGci...",
"token_type": "Bearer"
}
Step 8: Inspect the Token
Copy the access_token value and go to jwt.io. Paste it into the left box. On the right you will see three sections:
- Header — the algorithm used to sign the token (
RS256— RSA with SHA-256) - Payload — the claims: who the user is, what realm, when it expires
- Signature — Keycloak’s cryptographic signature proving the token has not been tampered with
Key claims to understand:
| Claim | Meaning |
|---|---|
sub |
The user’s unique internal ID in Keycloak. Never changes even if username changes. |
iss |
Issuer — the realm that created this token. |
azp |
Authorized party — the client that requested this token. |
exp |
Expiry — unix timestamp when the token stops being valid. |
iat |
Issued at — unix timestamp when the token was created. |
scope |
What permissions this token carries. |
acr |
Authentication Context Class — 1 means basic password auth. |
What Just Happened
You sent a POST request to Keycloak’s token endpoint with grant_type=password, passing the client ID, client secret, username, and password all in one request. Keycloak received this, looked up testuser in the oauth-labs realm, verified the password hash, confirmed myclient is a registered confidential client with the correct secret, then signed a JWT with its private RSA key and returned it.
You took that access token and pasted it into jwt.io. jwt.io split the token at the two dots — header, payload, signature — base64-decoded the first two parts, and displayed the claims in plain JSON. The signature section stayed encoded because verifying it requires Keycloak’s public key. What you read in the payload was Keycloak’s signed assertion: this user authenticated at this time, from this realm, via this client, and the token expires at this unix timestamp.
This is the Resource Owner Password Credentials grant — the user’s credentials go directly to the client, which forwards them to Keycloak. The principle it shows is that even in the simplest flow, the app never stores the password — it exchanges it once for a short-lived token and discards it.In essence, you built a complete minimal OAuth setup: identity provider, client, user, and token issuance workflow.The token endpoint URL pattern is:
http://localhost:8080/realms/{realm-name}/protocol/openid-connect/token
This same endpoint is used in every lab. Only the grant_type and parameters change.
Lab 2 — Client Credentials Grant
Goal: Understand machine-to-machine auth where no user is involved.
When is this used? A backend service calling another backend service. A cron job hitting an API. A data pipeline authenticating against a resource server. No human in the loop.
Step 1: Create a New Client for Machine Auth
- Go to Clients → Create client
- Client ID:
machine-client - Click Next
- Capability config:
- Toggle Client authentication to ON
- Uncheck Standard flow and Direct access grants
- Check Service accounts roles — this is what enables client credentials
- Click Next
- Leave Login settings blank → Save
- Go to the Credentials tab and copy the client secret
Step 2: Request a Token with No User
curl -X POST http://localhost:8080/realms/oauth-labs/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=machine-client" \
-d "client_secret=YOUR_MACHINE_CLIENT_SECRET"
You will get back an access token. Paste it into jwt.io and notice:
- There is no
preferred_usernameoremail— there is no user - The
subfield contains the service account ID, not a human user - There is no
refresh_token— machine tokens are just refreshed by requesting a new one
{
"exp": 1777543723,
"iat": 1777543423,
"jti": "trrtcc:997b333f-ad99-ab1f-d687-e0f68ebc59ed",
"iss": "http://localhost:8080/realms/oauth-labs",
"aud": "account",
"sub": "9d41b2f0-f104-4e73-a1df-5f010406e234",
"typ": "Bearer",
"azp": "machine-client",
"acr": "1",
"allowed-origins": [
"/*"
],
"realm_access": {
"roles": [
"default-roles-sample",
"offline_access",
"uma_authorization"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "profile email",
"email_verified": false,
"clientHost": "172.17.0.1",
"preferred_username": "service-account-machine-client",
"clientAddress": "172.17.0.1",
"client_id": "machine-client"
}
Step 3: Build a Protected Flask API
Create a file called api.py:
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
KEYCLOAK_URL = "http://localhost:8080"
REALM = "oauth-labs"
JWKS_URI = f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/certs"
def get_token_from_header():
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
return auth_header.split(" ")[1]
def introspect_token(token):
response = requests.post(
f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/token/introspect",
data={
"token": token,
"client_id": "machine-client",
"client_secret": "YOUR_MACHINE_CLIENT_SECRET"
}
)
return response.json()
@app.route("/data")
def get_data():
token = get_token_from_header()
if not token:
return jsonify({"error": "No token provided"}), 401
token_info = introspect_token(token)
if not token_info.get("active"):
return jsonify({"error": "Token is invalid or expired"}), 401
return jsonify({
"message": "Here is your protected data",
"requested_by": token_info.get("sub")
})
if __name__ == "__main__":
app.run(port=3000)
Install dependencies and run:
pip install flask requests
python api.py
Step 4: Call the API With and Without a Token
First, get a token:
TOKEN=$(curl -s -X POST http://localhost:8080/realms/oauth-labs/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=machine-client" \
-d "client_secret=YOUR_MACHINE_CLIENT_SECRET" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
Call the API with the token:
curl http://localhost:3000/data -H "Authorization: Bearer $TOKEN"
Call the API without the token:
curl http://localhost:3000/data
The first call returns data. The second returns a 401. This is OAuth 2.0 protecting a resource.
What You Learned
You sent a POST to the token endpoint with grant_type=client_credentials, passing only the machine-client’s ID and secret — no username, no password. Keycloak verified the client credentials, found the service account attached to machine-client, and issued an access token representing the application itself, not any user.
You then started the Flask API and sent a request to /data with that token in the Authorization: Bearer header. The Flask app extracted the token from the header, forwarded it to Keycloak’s introspection endpoint along with its own client credentials, and Keycloak responded with "active": true plus the token’s claims. The Flask app read the sub field from those claims and returned it in the response. When you sent a request with no token, the Flask app never reached introspection — it returned 401 immediately because the header was missing entirely.
This is the Client Credentials grant. The principle it shows is that applications have their own identity separate from any user — a service can authenticate and be authorized without a human being involved at any point in the flow.
Lab 3 — Authorization Code Grant
Goal: Implement the main browser-based OAuth 2.0 flow manually, without using a library, so you can see every step.
When is this used? Any time a human user logs into a web application. This is the most common OAuth 2.0 flow.
How the Flow Works
User clicks Login
→ App redirects user to Keycloak
→ User enters credentials on Keycloak's login page
→ Keycloak redirects back to App with a ?code=...
→ App exchanges the code for tokens (server-to-server)
→ App receives access_token + refresh_token
The code is single-use and short-lived. The actual token exchange happens server-to-server so the token never touches the browser URL bar.
Step 1: Create a New Client for the Web App
- Go to Clients → Create client
- Client ID:
webapp - Click Next
- Capability config:
- Toggle Client authentication to ON
- Make sure Standard flow is checked
- Click Next
- Login settings:
- Valid redirect URIs:
http://localhost:3000/callback - Web origins:
http://localhost:3000
- Valid redirect URIs:
- Click Save
- Go to Credentials tab and copy the secret
Step 2: Build the Express App
Create a new folder and inside it create app.js:
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const app = express();
const KEYCLOAK_URL = 'http://localhost:8080';
const REALM = 'oauth-labs';
const CLIENT_ID = 'webapp';
const CLIENT_SECRET = 'YOUR_WEBAPP_CLIENT_SECRET';
const REDIRECT_URI = 'http://localhost:3000/callback';
// Step 1: User clicks login — redirect them to Keycloak
app.get('/login', (req, res) => {
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: crypto.randomBytes(16).toString('hex') // CSRF protection
});
const authUrl = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/auth?${params}`;
res.redirect(authUrl);
});
// Step 2: Keycloak redirects back here with ?code=...
app.get('/callback', async (req, res) => {
const { code, error } = req.query;
if (error) {
return res.send(`Login failed: ${error}`);
}
// Step 3: Exchange the code for tokens
const tokenResponse = await axios.post(
`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const { access_token, refresh_token, expires_in } = tokenResponse.data;
// Step 4: Decode the token payload (base64) to read user info
const payload = JSON.parse(Buffer.from(access_token.split('.')[1], 'base64').toString());
res.json({
message: 'Login successful',
user: {
username: payload.preferred_username,
email: payload.email,
name: payload.name
},
token_expires_in_seconds: expires_in,
raw_access_token: access_token
});
});
app.listen(3000, () => console.log('App running at http://localhost:3000'));
Install dependencies and run:
npm init -y
npm install express axios
node app.js
Step 3: Walk Through the Flow
- Open
http://localhost:3000/loginin your browser - You get redirected to Keycloak’s login page — this is Keycloak’s UI, not your app’s
- Log in with
testuser / password123 - Keycloak redirects you back to
http://localhost:3000/callback?code=...&state=... - Your app catches the code, sends it to Keycloak server-to-server, and receives tokens
- You see the user’s info and the raw access token in the response
Open the browser dev tools and watch the Network tab during the flow. You will see:
- The redirect to Keycloak (302)
- The redirect back to your callback (302)
- The token exchange POST from your server (you won’t see this in the browser — it’s server-side)
What You Learned
You visited /login in your browser. Your Express app built an authorization URL containing the client ID, redirect URI, requested scopes, and a random state value for CSRF protection, then sent a 302 redirect to Keycloak. Your browser followed that redirect and landed on Keycloak’s login page — a page served entirely by Keycloak, not your app.
You entered testuser / password123 on that Keycloak page. Keycloak authenticated the user, generated a short-lived single-use authorization code, and redirected your browser back to http://localhost:3000/callback?code=...&state=.... Your browser followed that redirect — your Express app’s /callback handler received the request, extracted the code from the query string, then made a server-to-server POST directly from Express to Keycloak’s token endpoint, passing the code, redirect URI, client ID, and client secret. Keycloak verified that the code matched what it had issued, that the redirect URI matched the registered one, and that the client secret was correct. It then returned the access token and refresh token to your Express server. Your server decoded the token payload, extracted the username and email, and sent them back to the browser as JSON.
This is the Authorization Code grant. The principle it shows is that the token exchange happens entirely server-to-server — the actual tokens never appear in the browser URL bar, in browser history, or in any redirect. The code that does appear in the URL is useless alone; it requires the client secret to redeem and expires in seconds.
Lab 4 — PKCE (Proof Key for Code Exchange)
Goal: Extend the auth code flow to work safely for public clients (no client secret).
When is this used? Single-page apps (SPAs), mobile apps, and desktop apps where a client secret cannot be safely stored. If a user can inspect your app’s source code or binary, a secret is not really secret.
The Problem PKCE Solves
In the standard auth code flow, the secret proves your app is legitimate. But a SPA has no secret. If someone intercepts the authorization code from the redirect URL (via a malicious redirect or browser extension), they could exchange it for tokens.
PKCE fixes this by having your app generate a random code_verifier, hash it to create a code_challenge, send the hash at the start, and then send the original value at token exchange. Keycloak verifies they match. An interceptor who only got the code cannot complete the exchange without the original code_verifier.
Step 1: Update the webapp Client
- Go to Clients →
webapp→ Settings - Under Capability config, find Proof Key for Code Exchange (PKCE) and set it to required
- Save
Step 2: Create a Plain HTML Frontend
Create index.html:
<!DOCTYPE html>
<html>
<head><title>PKCE Demo</title></head>
<body>
<button onclick="login()">Login with PKCE</button>
<div id="result"></div>
<script>
const KEYCLOAK_URL = 'http://localhost:8080';
const REALM = 'oauth-labs';
const CLIENT_ID = 'webapp';
const REDIRECT_URI = 'http://localhost:3000/pkce-callback';
// Generate a random code_verifier (43-128 chars)
function generateCodeVerifier() {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// Hash the verifier to get the code_challenge
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function login() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Store verifier in sessionStorage — we need it when the code comes back
sessionStorage.setItem('code_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
state: crypto.randomUUID()
});
window.location = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/auth?${params}`;
}
// Handle callback if we are on the redirect page
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) return;
const verifier = sessionStorage.getItem('code_verifier');
// Exchange code + verifier for tokens (no client_secret needed)
const response = await fetch(
`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier // Send the original verifier, NOT the hash
})
}
);
const tokens = await response.json();
const payload = JSON.parse(atob(tokens.access_token.split('.')[1]));
document.getElementById('result').innerHTML = `
<p>Logged in as: <strong>${payload.preferred_username}</strong></p>
<p>Token expires in: ${tokens.expires_in}s</p>
`;
}
if (window.location.pathname === '/pkce-callback') {
handleCallback();
}
</script>
</body>
</html>
Serve this file using your Express app by adding a route to app.js:
app.get('/', (req, res) => res.sendFile(__dirname + '/index.html'));
app.get('/pkce-callback', (req, res) => res.sendFile(__dirname + '/index.html'));
Step 3: Walk Through the Flow
- Open
http://localhost:3000and click Login with PKCE - Notice the authorization URL now contains
code_challengeandcode_challenge_method=S256 - Log in at Keycloak
-
You are redirected back with a code
http://localhost:3000/pkce-callback? state=a8395ee6-2641-4888-95ad-efc2f150e506 &session_state=h3kzb-kfbQPOMEjyjtA2XY3l &iss=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Fsample &code=1146c442-f6c1-e329-5002-3f4854b97870.h3kzb-kfbQPOMEjyjtA2XY3l.6349b5f0-326f-406c-8800-16e8f0410a7d - The browser-side JS exchanges the code using the
code_verifier— no client secret in the request
What You Learned
You clicked Login in the browser. The JavaScript generated a random 32-byte code_verifier, ran it through SHA-256 using the Web Crypto API, base64url-encoded the result to produce the code_challenge, stored the original verifier in sessionStorage, then redirected your browser to Keycloak with the challenge and code_challenge_method=S256 added to the authorization URL — but no client secret, because there is none.
You logged in at Keycloak. Keycloak stored the code_challenge alongside the authorization code it generated, then redirected your browser back to /pkce-callback?code=.... The JavaScript on that page retrieved the code_verifier from sessionStorage, then sent a POST directly from the browser to Keycloak’s token endpoint with the code and the verifier — again, no client secret. Keycloak took the verifier it just received, hashed it with SHA-256, compared it to the code_challenge it had stored at the start, confirmed they matched, and issued the tokens.
This is the Authorization Code grant with PKCE. The principle it shows is that the code_verifier acts as a per-request proof of identity in place of a static secret. An attacker who intercepts the authorization code from the redirect URL cannot exchange it because they never had the code_verifier — it only existed in memory in your browser and was never transmitted during the redirect phase.
Lab 5 — Scopes and Claims
Goal: Create custom scopes, attach claims to them, and enforce them in an API.
When is this used? When you want to limit what data a token carries and what actions it allows. A token for a read-only client should not have write permissions.
Step 1: Create a Custom Scope
- Go to Client scopes in the left sidebar
- Click Create client scope
- Name:
read:orders - Type:
Optional(must be explicitly requested) - Click Save
Repeat to create write:orders.
Step 2: Add a Protocol Mapper to the Scope
A mapper injects data into the token when a scope is requested.
- Click on the
read:ordersscope - Go to the Mappers tab
- Click Add mapper → By configuration → Hardcoded claim
- Name:
orders-permission - Token claim name:
permissions - Claim value:
read - Add to access token: ON
- Click Save
Repeat for write:orders with claim value write.
Step 3: Assign the Scopes to Your Client
- Go to Clients →
myclient→ Client scopes tab - Click Add client scope
- Add both
read:ordersandwrite:ordersas Optional
Step 4: Request a Token With Specific Scopes
Request a token asking only for read:orders:
curl -X POST http://localhost:8080/realms/oauth-labs/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=myclient" \
-d "client_secret=YOUR_SECRET" \
-d "username=testuser" \
-d "password=password123" \
-d "scope=openid profile read:orders"
Paste the access token into jwt.io. You will see "permissions": "read" in the payload.
Request again with scope=openid profile write:orders and you will see "permissions": "write".
Request without either scope and permissions will not appear at all.
Step 5: Build a Scope-Enforcing API
Create orders_api.py:
from flask import Flask, request, jsonify
import requests
import json
import base64
app = Flask(__name__)
KEYCLOAK_URL = "http://localhost:8080"
REALM = "oauth-labs"
def decode_token(token):
# Decode the payload without verification (for demo only)
# In production always verify the signature using the JWKS endpoint
payload = token.split('.')[1]
# Add padding if needed
payload += '=' * (4 - len(payload) % 4)
return json.loads(base64.b64decode(payload))
def get_token():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
return auth.split(" ")[1]
@app.route("/orders", methods=["GET"])
def get_orders():
token = get_token()
if not token:
return jsonify({"error": "No token"}), 401
claims = decode_token(token)
permissions = claims.get("permissions", "")
if "read" not in permissions:
return jsonify({"error": "Insufficient scope — need read:orders"}), 403
return jsonify({"orders": [{"id": 1, "item": "Widget"}, {"id": 2, "item": "Gadget"}]})
@app.route("/orders", methods=["POST"])
def create_order():
token = get_token()
if not token:
return jsonify({"error": "No token"}), 401
claims = decode_token(token)
permissions = claims.get("permissions", "")
if "write" not in permissions:
return jsonify({"error": "Insufficient scope — need write:orders"}), 403
return jsonify({"message": "Order created"}), 201
if __name__ == "__main__":
app.run(port=4000)
Run it and test with tokens that have and do not have the write:orders scope. A 403 means the token is valid but the scope is missing. A 401 means no token at all. These are different errors for a reason.
What You Learned
Scopes control what a token is allowed to do. Claims are the data inside the token. Scopes and claims are how OAuth 2.0 implements least-privilege access — your app only gets the permissions it explicitly asks for, and only if they have been granted.
Lab 6 — Token Introspection vs Local JWT Validation
Goal: Implement both token validation strategies and understand the tradeoff.
The question every API faces: When a request arrives with a Bearer token, how do you know it is valid?
Strategy 1: Local JWT Validation
Your API downloads Keycloak’s public key (from the JWKS endpoint) and verifies the token’s signature locally. No network call to Keycloak needed per request.
import jwt
import requests
from jwt.algorithms import RSAAlgorithm
JWKS_URI = "http://localhost:8080/realms/oauth-labs/protocol/openid-connect/certs"
def get_public_key():
jwks = requests.get(JWKS_URI).json()
# Get the first RSA key
key_data = [k for k in jwks['keys'] if k['kty'] == 'RSA'][0]
return RSAAlgorithm.from_jwk(key_data)
PUBLIC_KEY = get_public_key()
def validate_token_locally(token):
try:
claims = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"],
audience="account"
)
return claims, None
except jwt.ExpiredSignatureError:
return None, "Token expired"
except jwt.InvalidTokenError as e:
return None, str(e)
Pros: Fast — no network call. Scales to any number of requests.
Cons: Cannot detect revoked tokens until they naturally expire. If a user is logged out or a token is revoked in Keycloak, your API will still accept it until exp passes.
Strategy 2: Token Introspection
Your API calls Keycloak’s introspection endpoint for every request. Keycloak checks its database and tells you if the token is currently active.
def validate_token_introspect(token):
response = requests.post(
"http://localhost:8080/realms/oauth-labs/protocol/openid-connect/token/introspect",
data={
"token": token,
"client_id": "myclient",
"client_secret": "YOUR_SECRET"
}
)
data = response.json()
if data.get("active"):
return data, None
return None, "Token is not active"
Pros: Always accurate. Detects revoked tokens immediately. Cons: One network call to Keycloak per API request. Adds latency. Keycloak becomes a dependency in your request path.
Which to Use
Use local validation for most cases — it is fast and stateless. Use introspection only for high-security operations (admin actions, financial transactions) where you need to be certain the token has not been revoked since it was issued. Many production systems use local validation with short token lifetimes (5 minutes) to limit the revocation window.
Lab 7 — Refresh Tokens
Goal: Implement the full token lifecycle — detect expiry, refresh silently, and handle revocation.
The problem: Access tokens expire in 5 minutes by default. You do not want to force users to log in every 5 minutes.
The solution: When the access token expires, use the refresh token to silently get a new one. The refresh token lives much longer (typically 30 minutes to hours).
Step 1: Implement Token Refresh in Node.js
Add this to your app.js:
const tokens = {}; // In-memory store (use a real session store in production)
app.get('/refresh', async (req, res) => {
const refreshToken = tokens['testuser']?.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token stored' });
}
try {
const response = await axios.post(
`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
tokens['testuser'] = response.data;
res.json({ message: 'Token refreshed', expires_in: response.data.expires_in });
} catch (err) {
res.status(401).json({ error: 'Refresh failed — user must log in again', detail: err.response?.data });
}
});
Step 2: Check Token Expiry Before Every Request
function isTokenExpired(accessToken) {
const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
const nowInSeconds = Math.floor(Date.now() / 1000);
return payload.exp < nowInSeconds;
}
app.get('/protected', async (req, res) => {
let userTokens = tokens['testuser'];
if (!userTokens) {
return res.status(401).json({ error: 'Not logged in' });
}
// If expired, try to refresh silently
if (isTokenExpired(userTokens.access_token)) {
console.log('Access token expired, refreshing...');
// Call the refresh logic here
}
res.json({ message: 'Access granted', user: 'testuser' });
});
Step 3: Test Revocation
- Log in through your app and store the refresh token
- In Keycloak Admin Console, go to Sessions → find
testuser→ click Sign out - Try to use the stored refresh token to get a new access token
- You will get:
{"error":"invalid_grant","error_description":"Session not active"}
This is how server-side logout works. The refresh token is tied to a Keycloak session. When the session is killed, the refresh token dies with it.
What You Learned
The access token and refresh token have different purposes and lifetimes. The access token is short-lived and stateless. The refresh token is longer-lived and stateful — Keycloak tracks it. When a user logs out, Keycloak invalidates the session which invalidates the refresh token. The access token technically remains valid until its exp — which is why keeping access token lifetimes short matters.
Lab 8 — Token Revocation and Proper Logout
Goal: Implement real logout — not just clearing cookies, but actually invalidating the session in Keycloak.
Why this matters: If you only clear the cookie or session on your side, the Keycloak SSO session still exists. The user could visit another app in the same realm and be silently logged back in without entering credentials again.
The Three Logout Steps
A complete logout requires three things:
- Revoke the refresh token at Keycloak’s revocation endpoint
- Clear your app’s local session
- Redirect to Keycloak’s end-session endpoint to kill the SSO session
Step 1: Revoke the Refresh Token
app.get('/logout', async (req, res) => {
const userTokens = tokens['testuser'];
if (userTokens?.refresh_token) {
// Step 1: Tell Keycloak this refresh token is dead
await axios.post(
`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/revoke`,
new URLSearchParams({
token: userTokens.refresh_token,
token_type_hint: 'refresh_token',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
}
// Step 2: Clear local session
delete tokens['testuser'];
// Step 3: Redirect to Keycloak end-session to kill SSO session
const endSessionUrl = new URLSearchParams({
post_logout_redirect_uri: 'http://localhost:3000',
client_id: CLIENT_ID
});
res.redirect(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/logout?${endSessionUrl}`);
});
Step 2: Test the Difference
Bad logout (just clearing local state):
- Log in via
/login - Delete the cookie manually without calling Keycloak
- Open another tab and go to
/loginagain - You are immediately logged in with no password prompt — the Keycloak SSO session survived
Proper logout:
- Log in via
/login - Visit
/logout - Try to log in again via
/login - You are taken to Keycloak’s login page and must enter credentials again
What You Learned
OAuth 2.0 has two distinct session layers. Your app’s session and Keycloak’s SSO session. You must terminate both for a real logout. Most security bugs in OAuth implementations come from only clearing the local session and leaving the Keycloak session alive.
Lab 9 — Device Authorization Grant
Goal: Implement the OAuth 2.0 Device Flow for CLI tools and devices without a browser.
When is this used? CLI tools (aws configure sso, gcloud auth login), smart TVs, IoT devices — anything where opening a browser is impossible or impractical.
How the Flow Works
CLI requests a device code from Keycloak
→ Keycloak returns: device_code, user_code, verification_uri
→ CLI prints: "Go to http://localhost:8080/device and enter: ABCD-1234"
→ User goes there on their phone/laptop and approves
→ CLI polls Keycloak every 5 seconds with the device_code
→ Once user approves, Keycloak returns tokens to CLI
Step 1: Enable Device Flow on the Client
- Go to Clients →
myclient→ Settings - Under Capability config, check OAuth 2.0 Device Authorization Grant
- Save
Step 2: Write the CLI Tool
Create device_login.py:
import requests
import time
import json
KEYCLOAK_URL = "http://localhost:8080"
REALM = "oauth-labs"
CLIENT_ID = "myclient"
CLIENT_SECRET = "YOUR_SECRET"
def device_login():
# Step 1: Request a device code
response = requests.post(
f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/auth/device",
data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "openid profile email"
}
)
data = response.json()
device_code = data["device_code"]
user_code = data["user_code"]
verification_uri = data["verification_uri_complete"]
interval = data.get("interval", 5)
expires_in = data["expires_in"]
# Step 2: Show the user what to do
print(f"\n{'='*50}")
print(f"To sign in, visit: {verification_uri}")
print(f"Or go to {data['verification_uri']} and enter code: {user_code}")
print(f"{'='*50}")
print(f"Waiting for you to approve... (expires in {expires_in}s)\n")
# Step 3: Poll until the user approves or it expires
start = time.time()
while time.time() - start < expires_in:
time.sleep(interval)
token_response = requests.post(
f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
}
)
token_data = token_response.json()
if "access_token" in token_data:
payload = json.loads(__import__('base64').b64decode(
token_data['access_token'].split('.')[1] + '=='
))
print(f"Logged in as: {payload.get('preferred_username')}")
print(f"Token expires in: {token_data['expires_in']}s")
return token_data
error = token_data.get("error")
if error == "authorization_pending":
print("Still waiting...")
elif error == "slow_down":
interval += 5
print("Slowing down polling...")
elif error == "expired_token":
print("Device code expired. Please run the script again.")
return None
elif error == "access_denied":
print("User denied the request.")
return None
print("Timed out.")
return None
if __name__ == "__main__":
device_login()
Run it:
python device_login.py
Open the printed URL in your browser, log in as testuser, and approve. Watch the CLI receive the token.
What You Learned
The device flow decouples where the user authenticates (their phone/laptop) from where the app runs (a CLI or TV). The device_code is the link between the two. Polling with backoff is required because Keycloak cannot push results to the CLI — it can only respond to polls.
Lab 10 — Putting It All Together: Multi-Client OAuth Architecture
Goal: Run three different OAuth clients simultaneously, each using the right grant type, to model a real microservices architecture.
Architecture
Browser User
→ SPA (PKCE) → calls → Orders API
→ calls → Inventory Service (Client Credentials)
CLI User
→ CLI Tool (Device Flow) → calls → Orders API
What Each Client Does
SPA (public client, PKCE):
- User logs in via browser
- Receives an access token
- Calls the Orders API with
Authorization: Bearer <token>
Orders API (resource server + confidential client):
- Validates incoming user tokens locally using Keycloak’s public key
- Uses client credentials to call the Inventory Service as itself
Inventory Service (resource server):
- Accepts only machine tokens (client credentials)
- Validates that
azp(the caller) is the Orders API client
CLI Tool (device flow):
- Allows a user to authenticate without a browser
- Gets a user token it can use to call the Orders API
Key Insight
Notice that the Orders API plays two roles simultaneously. It is a resource server (it accepts and validates user tokens) and also an OAuth client (it requests its own token to call downstream services). This dual role is extremely common in microservices. The user token flows from the browser to the Orders API. A separate machine token — with different scopes and identity — flows from the Orders API to the Inventory Service. These are completely separate tokens, separate trust relationships.
What to Verify
- Log in via the SPA and call the Orders API — succeeds with a user token
- Log in via the CLI and call the Orders API — succeeds with a different user token
- Try calling the Inventory Service directly with a user token — fails with 403 (it only accepts machine tokens)
- Try calling the Orders API with no token — fails with 401
This is OAuth 2.0 in production: different clients, different grant types, token validation at every service boundary, least-privilege scopes throughout.
Quick Reference
Token Endpoint
POST http://localhost:8080/realms/{realm}/protocol/openid-connect/token
Grant Type Cheat Sheet
| Grant Type | Use Case | Has User? | Client Secret? |
|---|---|---|---|
client_credentials |
Service-to-service | No | Yes |
authorization_code |
Web app with browser | Yes | Yes |
authorization_code + PKCE |
SPA / mobile app | Yes | No |
device_code |
CLI / TV / IoT | Yes | Optional |
refresh_token |
Renew expired access token | Yes | Yes |
Useful Keycloak Endpoints
| Endpoint | URL |
|---|---|
| Token | /realms/{realm}/protocol/openid-connect/token |
| Auth (redirect) | /realms/{realm}/protocol/openid-connect/auth |
| Introspection | /realms/{realm}/protocol/openid-connect/token/introspect |
| Revocation | /realms/{realm}/protocol/openid-connect/revoke |
| JWKS (public keys) | /realms/{realm}/protocol/openid-connect/certs |
| End session | /realms/{realm}/protocol/openid-connect/logout |
| Discovery | /realms/{realm}/.well-known/openid-configuration |
JWT Claim Reference
| Claim | Meaning |
|---|---|
sub |
Subject — the user’s unique ID |
iss |
Issuer — the realm URL |
aud |
Audience — who the token is intended for |
azp |
Authorized party — the client that requested it |
exp |
Expiry — unix timestamp |
iat |
Issued at — unix timestamp |
acr |
Auth method (1 = password, higher = MFA) |
scope |
Scopes granted |
sid |
Session ID in Keycloak |