ZB Field Notes

I brokered my sign-in through Keycloak, and broke my own owner check

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.

Diagram: browser to zakaria.lu to auth.zakaria.lu to linkedin.com, with the broker highlighted
The non-conformance did not disappear. It became someone else’s maintained problem.

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.

Three cards: IdP mapper, user profile declaration, protocol mapper, with the middle one highlighted
Keycloak 26 runs the declarative user profile with unmanaged attributes disabled. An undeclared attribute is dropped on write, with no error.

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.

Two panels: the permissive user-profile config I wrote, and the account-console email change it allowed
I wrote every user-profile attribute as edit: [admin, user], with a comment claiming the IdP importer needed it. It did not.

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. email stopped being an immutable assertion from a third party and became mutable state in a system I now operate.

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:

RealmBrute-force protectionWho chose it
cvnext — minetrue, lockout after 10My realm file
master — the admin realmfalse. 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.

Terminal output showing a 200 on the console path and a 302 whose Location header was never inspected
curl does not follow redirects. Your browser does. That one difference was the entire bug.

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-realm skips 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.