The exposure
The first Keycloak rollout worked on the first try, which is the dangerous part. Under the hood it was one realm for every app and every tenant, wildcard redirect URIs, a client secret embedded in a single-page app, and roles assigned directly to individual users. None of it threw an error, and that is precisely why it festered for six months until we had three apps, two customer tenants, and a growing suspicion the whole thing was held together with tape.
Understanding keycloak realms clients roles correctly is a security exercise, not a modeling preference, because each of those four sins maps to a concrete class of vulnerability. A working login flow and a safe one look identical from the outside, and the console will happily let you build the former long before you have the latter. Before changing anything it was worth cataloguing exactly what each shortcut exposed us to. For the concept model and the remediation I worked from a thorough third-party guide to Keycloak's core concepts → alongside the official administration guide.
Threat model
- No tenant isolation — shared blast radius. One realm carrying all apps and both tenants means a misconfiguration, a leaked admin credential, or a bad role change in one tenant's context has blast radius across every tenant. There is no boundary to contain a mistake, which is the opposite of what a realm is for.
-
Wildcard redirect URIs — open redirect and token exfiltration.
http://localhost:3000/*was convenient in dev and quietly rode into prod. A wildcard redirect lets an attacker steer the authorization-code callback to a URL they control, turning a valid login into a token-leak primitive. - Client secret in a SPA — a secret that is not secret. The single-page app was registered as if it could hold a secret; the secret shipped in the browser bundle, readable by anyone. A confidential client whose secret is public is a confidential client in name only, and it undermines the entire client-authentication assumption.
- Per-user role assignment — unauditable and unscalable. Assigning roles to individuals by hand means "who can do X?" is answerable only by enumerating every user, and every onboarding is a manual chance to over-grant. Least privilege is not enforceable if entitlements are scattered.
The organizing principle for the fix is the three-word model: realms isolate identities, clients integrate apps, roles authorize actions. Every defect above was a violation of one of those three boundaries, so the remediation is to restore each boundary deliberately.
Controls we added
Control 1 — realms as isolation boundaries
A realm is a self-contained boundary with its own users, groups, clients, and roles — Keycloak's definition is that "a realm manages a set of users, credentials, roles, and groups" (Server Administration Guide). We split by tenant, and at minimum by environment (dev/staging/prod), so a misconfiguration in one is contained to one. Users no longer span realms automatically, and that is the point: if identity genuinely needs to be shared, identity brokering or federation is the explicit, reviewable tool for it, rather than an implicit shared bucket.
There is a ceiling to this control worth stating honestly, because over-applying it creates its own risk. Realm-per-tenant is right for a handful of tenants but stops scaling around a couple dozen — past that you are operating an IAM platform instead of shipping a product, and a sprawl of realms becomes its own misconfiguration surface. Keycloak's Organizations feature (preview in 25, GA in 26 — announcement) provides first-class multi-tenancy inside a single realm for that case. The rule: separate realms to isolate genuinely different security domains (different admins, or dev/staging/prod); Organizations to partition many customers who share one app's trust boundary.
Control 2 — clients with an honest access type and PKCE
A client's access type is a security control, not a convenience setting:
- public — SPAs and native apps; no secret, because anything shipped to a browser or device is readable.
- confidential — server-side apps that can actually keep a secret.
- bearer-only — APIs that verify tokens and never start a login flow.
The SPA became a public client, and the leaked secret was retired rather than rotated-in-place, because the fix is to stop pretending a browser bundle is private, not to ship a fresh secret into the same exposure. What replaces the secret is PKCE (S256): designed specifically for OAuth public clients that cannot keep a secret (RFC 7636), it stops an intercepted authorization code from being redeemed by an attacker (Auth0's PKCE explainer). Turning it on is non-negotiable for browser and native clients. Alongside it, the wildcards died: valid redirect URIs and web origins are now exact entries, no *, to close the open-redirect vector.
Control 3 — roles and groups for least privilege by construction
Roles model permissions, and we now use two flavors deliberately: realm roles for org-wide levels (admin, editor, viewer) and client roles for app-specific permissions (billing:read). Composite roles let one Admin assignment imply a bundle — though a composite is also where over-grant hides, so each composite is reviewed for exactly what it expands to. The biggest change was mapping roles to groups, not individuals: assigning to users does not scale and is not auditable; group-based mapping is both. A new hire joins a group and inherits precisely the intended roles, which makes least privilege the default path rather than a discipline someone has to remember.
Control 4 — authorize from the token, not from identity
The refactor only pays off if the application uses the structure. The early mistake was scattering permission checks keyed on usernames — a hard-coded allow-list is unauditable and drifts silently. The clean version reads roles straight from the token: in OIDC, Keycloak places realm roles under realm_access.roles and client roles under resource_access[client].roles. An authorization check becomes "does this token carry the editor role?" rather than "is this user in my admin list?" Because Admin is modeled as a composite, one role in the token expands to everything it should, consistently. Permissions become a configuration concern in Keycloak, not a deployment concern in the codebase — a new hire is added to a group, the roles land in their token, and the app authorizes them with no code change or redeploy.
Verifying the controls, not just shipping them
The rebuild started from a minimal, reviewable baseline rather than clicking around the console:
docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:25.0.2 start-dev
Then a five-step skeleton: a dedicated realm per security domain, each app as its own client with exact redirect URIs and the correct access type, realm roles like Admin and User, and assignment via groups. The verification that mattered most was that the controls are now demonstrable: "who can write billing?" used to mean grepping code and cross-referencing a user table; now it is "which groups carry a role that composes billing:write?", answerable in the admin console in seconds. An entitlement you can query is an entitlement you can audit, and negative-testing a redirect URI with a deliberately invalid value confirms the allow-list actually rejects what it should.
Residual risk / what we're still watching
Restoring the three boundaries removed the acute exposures, but each control carries its own residual risk.
- Realm sprawl vs. Organizations. Separate realms isolate cleanly at small scale but become an operational and misconfiguration burden past a couple dozen tenants. We are watching the tenant count against the point where Organizations-in-one-realm is the safer trade, since the wrong choice in either direction reintroduces risk — sprawl on one side, a weaker isolation boundary on the other.
- PKCE and redirect-URI drift. The public-client posture depends on PKCE staying enforced and redirect URIs staying exact. A future convenience edit that re-adds a wildcard, or a client that quietly disables PKCE, silently reopens the token-leak vector. Both live in reviewed configuration.
- Composite-role over-grant. Composites are ergonomic but concentrate authority; a role added to a widely-assigned composite grants it to everyone downstream at once. We review composite membership as an entitlement change, not a convenience.
- Group hygiene. Group-based assignment scales, but it moves the risk to group membership — a stale or over-broad group is now the thing to audit. We periodically reconcile group membership against intended access.
- The leaked secret is retired but assume-compromised. Because the old SPA secret was public for months, we treat it as burned permanently and monitor for any client still configured to accept it.
The lesson that stuck is that the console lets you build something that works long before it is something that is safe, and the three-word model — isolate, integrate, authorize — is both the setup mantra and the operating model that keeps the system explainable, and auditable, a year later.
Sources & further reading
- Keycloak — Server Administration Guide — realms, clients, roles, groups, and composite roles.
- Keycloak — Organizations announcement — multi-tenancy inside a single realm (preview in 25, GA in 26).
- RFC 7636 — Proof Key for Code Exchange — why public clients use PKCE instead of a secret.
- Auth0 — Authorization Code Flow with PKCE — a clear walkthrough of the SPA/native login flow.
- A Keycloak realms/clients/roles walkthrough — a useful third-party account of the pitfalls, the cross-realm FAQ, and a runnable five-step example.
Top comments (0)