Keycloak part 1: reading the login URL, parameter by parameter
I have wired Keycloak into Spring Boot more than once and never really read what it does. You click Log in, the browser flickers through two redirects, and you are authenticated. Whatever happened in between happened too fast to see.
So I built a playground — Keycloak 26.7, Spring Boot 4.1, Spring Security 7.1 — and started at the beginning. This is part 1 of a series: the Authorization Code flow, read parameter by parameter, from a running lab rather than from a spec.
Freezing the redirect
The problem with reading the login URL is that you never get to. The fix turned out to be small. Spring builds that URL with DefaultOAuth2AuthorizationRequestResolver, and OAuth2AuthorizationRequestRedirectFilter is just the thing that follows it. So I borrowed the resolver and rendered its output instead of redirecting:
OAuth2AuthorizationRequest req = resolver.resolve(request, "keycloak");
model.addAttribute("uri", req.getAuthorizationRequestUri());
model.addAttribute("attributes", req.getAttributes());
That matters more than it looks. Hand-rolling the URL from the RFC would only ever confirm my own assumptions; borrowing the framework's own component means what I am looking at is the request. Here is what came back:
GET http://localhost:8180/realms/playground/protocol/openid-connect/auth
response_type code
client_id spring-web
scope openid profile email
state Ca1CyemojepvmjkZpldnbyVoQhecHcuU0taQQFOhJJI=
redirect_uri http://localhost:8090/login/oauth2/code/keycloak
nonce FjT8VLCv2YbJ537FN_RzC1Qb_X25Ll2OtyQn08vRgG0
code_challenge 83H_-FF6O2zoOBjuqLWUO4O-P97Rz25_YcE_Dn-dixQ
code_challenge_method S256
Every one of those values sits in the address bar, in browser history, and in any proxy between you and Keycloak. None of it is secret. That is not a weakness — it is the design, and the rest of this post is why it holds anyway.
response_type=code is the single word that names the flow. Ask for a token there instead and you get the implicit flow, which is deprecated for precisely the reason this post is about.
Three lookalike strings, three different jobs
state, nonce and code_challenge all look like the same base64 noise, all round-trip, and all get checked on the way back. I had quietly filed them together as “the security ones”. They are not interchangeable at all.

Written out: state binds the callback to your browser session. nonce binds the ID token to this one request. code_challenge binds the code to the client that asked for it. Drop any one of them and the other two do not cover the gap.
Send the hash, keep the thing that made it
PKCE is the one most people can name. The client invents a random code_verifier, sends only its SHA-256 as code_challenge, and produces the verifier at redemption time. Steal the URL, steal the code out of the redirect, and you still cannot exchange it — the value that proves ownership was never transmitted.
I checked that rather than assuming it, and found the pattern applied twice:

The nonce half surprised me. Spring keeps the raw nonce in the session and puts its SHA-256 in the URL; Keycloak copies that hashed value into the ID token; Spring re-hashes its stored copy and compares on the way back. The reasoning is sound — ID tokens get handled, logged and forwarded, so whoever sees one learns a hash and never the raw value, and cannot forge a request that would validate against it.
The bit I had wrong
I went in expecting to enable PKCE by hand. Every guide says confidential clients need it switched on explicitly:
resolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce());
I never called it. The request carried code_challenge_method=S256 regardless. On Spring Security 7.1 the default resolver applies PKCE to confidential clients too.
What makes this more than trivia is how it surfaced. My Keycloak client sets pkce.code.challenge.method: S256, which makes a challenge mandatory. If Spring had not been sending one, every login since day one would have failed. They had not. The conclusion arrived backwards, from evidence that had been sitting in front of me for days — and it means any tutorial telling you to add withPkce() for a confidential client is describing an older Spring Security.
What the browser is actually worth
Put it together and the payoff is that owning the browser completely buys an attacker nothing.

The redirect whitelist is worth measuring rather than trusting. Tampering redirect_uri to http://evil.test/steal returns 400 Invalid parameter: redirect_uri before a login form is even rendered; the untampered request returns 302. That one check is what stops an open redirector from becoming a token thief.
What I am taking into part 2
Two things stuck.
Three bindings, three attacks. That framing is the only one that has kept state, nonce and code_challenge separate in my head.
Send a hash, keep the thing that made it. It appears twice in a single HTTP request here. Once you have the shape you start seeing it everywhere in OAuth — token introspection, client assertions, DPoP.
Next: the token exchange itself. What actually comes back, why the ID token and the access token carry different audiences, and why an API that validates offline cannot notice you were fired thirty seconds ago.
The lab is public if you want to run it: github.com/zakariahere/keycloak-playground. One command, seeded users, and a /flow page that freezes the redirect so you can read it yourself.