I brokered my sign-in through Keycloak, and broke my own owner check
My site has exactly one privileged user: me. Sign-in exists to gate a CV download and record
who asked. For two years that was a hand-wired LinkedIn OAuth client, and it carried three
apologies for LinkedIn’s non-conformant OIDC: the nonce stripped from the authorization
request because LinkedIn rejects it, token_type defaulted to Bearer because LinkedIn
omits it, and PKCE switched off because LinkedIn cannot do it.
So I put a self-hosted Keycloak in front. The app becomes a plain OIDC client of one realm; LinkedIn becomes a brokered identity provider inside it. About 240 lines of provider-specific code went away.

Two things fell out that I did not plan. PKCE came back on for free — Spring Security 7.1
defaults requireProofKey to true, so my explicit false had
been actively disabling it, and deleting a line was the whole fix. And local sign-in became
possible for the first time: the LinkedIn dev app has no localhost redirect URI, so my
owner-only kanban board had never once been openable on my own machine.
Then it got interesting.
Trap one: a claim needs three layers, and the middle one is invisible
The app persists picture and locale from the profile. Wiring a
brokered claim through looks like two steps — an identity-provider mapper to import it, a
protocol mapper to emit it. I did both. The attributes silently vanished.

The missing piece is a declaration in the realm’s user profile
(kc.user.profile.config) permitting that attribute to exist at all. Only
username, email, firstName and lastName ship
declared. Everything else is dropped.
What makes this nasty is that all three failure modes present identically — a null claim — while layers one and three still read as perfectly correct. In production the only symptom would have been visitor avatars quietly turning into initials, days after the deploy.
Trap two: a logout that returns 302 and does nothing
Spring’s OidcClientInitiatedLogoutSuccessHandler reads
end_session_endpoint from the registration’s
configurationMetadata. A hand-built ClientRegistration has that map
empty, and when the endpoint is missing the handler returns null and falls back to
a local-session logout. No exception. No log line. HTTP 302 either way.
.providerConfigurationMetadata(
Map.of("end_session_endpoint", publicIssuer + "/protocol/openid-connect/logout"))
Without that line the Keycloak SSO session outlives your app session, so “Sign out”
followed by “Sign in” re-authenticates instantly with no prompt — which reads
like a browser caching bug and sends you hunting in entirely the wrong place. There is no way to
detect it with curl. The broken path and the working path return the same status code.
The only test is a human clicking twice.
The one that mattered: my owner check now read a field the user could edit
Owner status on my site is an email string comparison — the email claim
against a configured address. That code did not change during the migration. Not one line.
What changed is where the email came from.

Keycloak serves an account console on the same host, grants manage-account to every
brokered user by default, and my realm had verifyEmail: false. So a recruiter who
signed in could open that console, change their email to mine, and get a 204 back
with no verification step. The next ID token carried it. That reaches visitor analytics —
names, emails, unmasked IPs — plus the blog admin and the private board.
The only thing standing in the way was duplicateEmailsAllowed: false. That is a
uniqueness constraint, not a security control, and the window where it does not help is real:
before my own first sign-in, after any change to the configured owner address, or after any realm
re-creation.
The claim didn’t change. The trust underneath it did.
I did not catch this. A multi-agent code review did, on the pull request, and it reproduced the
whole thing against a live Keycloak rather than inferring it from the diff. The fix is one line per
attribute — edit: ["admin"] — and I verified that admin-only editing still
lets both the realm import and the IdP importers write those attributes. The permission governs the
account console, not the broker.
A default I inherited without ever choosing it
The same review flagged that my Traefik rule had no path predicate, so
/admin/master/console/ was answering 200 to the internet. That is a fair thing to do
deliberately; it is a bad thing to do accidentally. But the sharper detail is what sits behind it:
| Realm | Brute-force protection | Who chose it |
|---|---|---|
cvnext — mine | true, lockout after 10 | My realm file |
master — the admin realm | false. No lockout. Unlimited attempts. | Keycloak’s default |
A strong admin password buys much less than you think against unlimited attempts. Lockout is
what makes the entropy count. And because master is not a realm I import, that
hardening cannot live in a committed realm file — a rebuild silently comes back
unprotected, and no test will notice. It is a documented manual step now, which is the honest
answer rather than a comfortable one.
I verified the component, not the path
The thread running through all of this is a habit, not a technology.

When I briefly put the admin console behind an SSH tunnel, I checked
/admin/master/console/ directly, got a 200, and accepted /admin/ → 302
without ever reading the Location header. It pointed at the public host, straight into
the 403 I had just built. Two follow-up pull requests to make one console reachable — each
“verified”, each verifying the wrong thing.
Same shape as the logout trap. I tested the piece that was convenient to test, and the piece I skipped was the one a person actually walks through.
What I would tell myself before starting
- Do not discover the issuer.
ClientRegistrations.fromIssuerLocation()makes a blocking HTTP call at bean creation with 30s timeouts. On a one-box deploy where the identity server and the app start together, that turns “Keycloak is slow” into “the application context failed” — taking the blog and everything else down with sign-in. The endpoint paths are fixed. Write them out. --import-realmskips a realm that already exists. Editing the committed JSON and restarting does nothing. Both security fixes above had to be applied to the live realm separately from the commit.- Re-audit every trust assumption a value used to carry, not just the code that reads it. Migrating auth is rarely about the comparison. It is about what the compared value silently used to guarantee.
Net result: fewer lines, PKCE on, local sign-in that finally works, and two escalation paths that never reached production. I will take that trade — but the second one was luck dressed up as process, and the process is the part I am keeping.