DEV Community

iapilgrim
iapilgrim

Posted on

Keycloak from Scratch, Part 2 — Authorization Code + PKCE, Built by Hand

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 realmpkce-demo
  • ClientsCreate client → Client ID: pkce-spa-clientNext
  • 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
  • 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
  • UsersAdd user → username dave, first/last name Dave/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)
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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", []),
    })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
  • state and code_verifier are 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)