In Part 1 we logged in by sending a username and password directly
to Keycloak. That's fine for a script, but no browser-facing app should
ever do that — it means the app itself sees the raw password, and it
can't participate in single sign-on. What every real login button should
do instead is redirect the user to Keycloak and get a token handed
back. That's the Authorization Code flow, and for anything that can't
keep a secret (a browser app, a mobile app), it needs a companion called
PKCE.
We're going to implement it by hand — no keycloak-js, no library — so
every step is visible instead of hidden behind keycloak.login().
What we're building
- A public Keycloak client (no secret — a browser can't keep one)
- A plain HTML + vanilla JS page that performs the full redirect flow
- A small Flask API that validates the resulting token independently, without ever talking to Keycloak per request
Prerequisites
- Everything from Part 1 (Keycloak running via Docker Compose)
- Python 3 with
pip install flask flask-cors "pyjwt[crypto]" - A browser
1. Create a new realm and a public client
- Admin Console → Create realm →
pkce-demo -
Clients → Create client → Client ID:
pkce-spa-client→ Next - Client authentication: Off — this is what makes it a public client. There's no secret to steal because there isn't one.
- Capability config: check Standard flow, uncheck Direct access grants (we don't want this client falling back to Part 1's flow) → Save
-
Settings tab:
-
Valid redirect URIs:
http://localhost:5173/* -
Web origins:
http://localhost:5173
-
Valid redirect URIs:
- Advanced tab → scroll to Proof Key for Code Exchange Code Challenge Method → set to S256
This last setting is the one that actually enforces PKCE server-side.
Without it, Keycloak will still accept a PKCE challenge if the client
sends one, but it won't require it — leaving the door open for a
client that skips PKCE entirely.
2. Create a role and a user
-
Realm roles → create
spa-user -
Users → Add user → username
dave, first/last nameDave/Demo(see Part 1 if you're wondering why that matters) -
Credentials tab → password
dave, Temporary: Off -
Role mapping → assign
spa-user
3. The concept: what PKCE actually proves
A confidential client proves its identity with a secret. A public client
can't do that — so PKCE proves something different: that the app
redeeming the authorization code is the same app that started the login.
Browser Keycloak
│ code_verifier = random(64 bytes) │
│ code_challenge = SHA256(code_verifier) │
├──── GET /auth?code_challenge=... ───────────────►│
│◄─── redirect back with ?code=... ─────────────────┤
├──── POST /token │
│ code=..., code_verifier=... ──────────────►│ (recomputes SHA256(code_verifier),
│◄─── access_token, id_token ───────────────────────┤ compares to the original challenge)
The code_verifier never leaves the browser tab until the final token
request. If an attacker somehow intercepts the redirect and grabs the
code (say, via a malicious app registered against the same custom
URL scheme on mobile), they still can't redeem it — they don't have the
verifier, and they can't derive it from the challenge, because SHA-256
doesn't run backwards.
4. Generating the challenge (app.js)
function base64UrlEncode(bytes) {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function randomString(length = 64) {
const arr = new Uint8Array(length);
crypto.getRandomValues(arr);
return base64UrlEncode(arr).slice(0, length);
}
async function sha256(input) {
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
}
Nothing exotic — crypto.subtle (native to every modern browser) does
the hashing, and the verifier is just cryptographically random bytes.
5. Kicking off the login
async function login() {
const codeVerifier = randomString(64);
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
const state = randomString(16);
// This never leaves the browser until step 6.
sessionStorage.setItem("pkce_code_verifier", codeVerifier);
sessionStorage.setItem("pkce_state", state);
const params = new URLSearchParams({
client_id: "pkce-spa-client",
redirect_uri: window.location.origin + "/index.html",
response_type: "code",
scope: "openid",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
window.location.href =
`http://localhost:8080/realms/pkce-demo/protocol/openid-connect/auth?${params}`;
}
The state parameter is a separate, unrelated safety net: a random value
checked on the way back to guard against CSRF — someone tricking your
browser into completing an authorization flow you didn't start.
6. Handling the redirect back
Keycloak sends the browser back to your redirect_uri with ?code=...&state=...
in the URL. This runs on page load:
async function handleCallback(code, returnedState) {
const expectedState = sessionStorage.getItem("pkce_state");
if (returnedState !== expectedState) {
throw new Error("state mismatch — possible CSRF, aborting");
}
const codeVerifier = sessionStorage.getItem("pkce_code_verifier");
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: "pkce-spa-client",
redirect_uri: window.location.origin + "/index.html",
code,
code_verifier: codeVerifier,
});
const resp = await fetch(
"http://localhost:8080/realms/pkce-demo/protocol/openid-connect/token",
{ method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }
);
const tokens = await resp.json();
sessionStorage.setItem("access_token", tokens.access_token);
sessionStorage.setItem("id_token", tokens.id_token);
window.history.replaceState({}, document.title, window.location.origin + "/index.html");
}
Keycloak re-derives SHA256(code_verifier) server-side and compares it
to the code_challenge it received in step 5. If they match, and the
code hasn't already been redeemed or expired, you get tokens back.
7. Validating the token on the API side
The API never talks to Keycloak per request — it fetches Keycloak's
public signing keys once (JWKS) and verifies signatures locally:
from flask import Flask, request, jsonify
from flask_cors import CORS
import jwt
from jwt import PyJWKClient
ISSUER = "http://localhost:8080/realms/pkce-demo"
CLIENT_ID = "pkce-spa-client"
jwks_client = PyJWKClient(f"{ISSUER}/protocol/openid-connect/certs")
app = Flask(__name__)
CORS(app, origins=["http://localhost:5173"])
@app.route("/api/me")
def me():
auth = request.headers.get("Authorization", "")
token = auth.split(" ", 1)[1] if auth.startswith("Bearer ") else None
if not token:
return jsonify({"error": "missing bearer token"}), 401
signing_key = jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token, signing_key.key, algorithms=["RS256"],
issuer=ISSUER, options={"verify_aud": False},
)
# Keycloak's default audience is "account", not the client id — check
# azp (authorized party) instead, which is always the requesting client.
if claims.get("azp") != CLIENT_ID:
return jsonify({"error": "unexpected azp"}), 401
return jsonify({
"preferred_username": claims.get("preferred_username"),
"roles": claims.get("realm_access", {}).get("roles", []),
})
Because Keycloak signs tokens asymmetrically (RS256), the API only ever
needs the public half of the key pair — it can validate tokens without
ever holding a shared secret with Keycloak.
8. Run it
python3 api/server.py & # :5001
python3 -m http.server 5173 & # :5173, serving the SPA
Open http://localhost:5173, click Log in, authenticate as
dave/dave, and you should land back on the page with decoded ID
token and access token claims printed, plus a working call to
/api/me.
What actually happened here
- Public client, no secret — PKCE substitutes possession of a one-time verifier for a shared secret the client can't keep anyway.
-
stateandcode_verifierare solving different problems — CSRF vs. authorization-code interception — don't conflate them. - Asymmetric JWT validation means your API and your identity provider never need to share a secret at all, unlike the client-secret approach from Part 1.
Next up, Part 3: back to server-to-server auth — client credentials
grants, service accounts, and when Direct Grant (Part 1) is and isn't
the right tool for machine identities.
Top comments (0)