Back to blog
Keycloak como emisor de identidad de la plataforma

Centralized identity management with Keycloak

LabKeycloakRBACIdentitySecurity

Delegating identity to an OIDC issuer can be explained in two sentences and takes quite a few more to implement. This post is the part that comes after: what the application asks for, what Keycloak returns, and how that translates into permissions inside a platform where each customer has its own space.

The case for why it makes sense —and what it means for an audit— is in the governance post. Here I get into the mechanism.

Why you don't write this by hand

I've held a firm stance on this for a while: if a team spends development time building its own sign-up, its own login, its password recovery, and all the paraphernalia that comes with it, it's wasting time and taking on a risk it doesn't need.

It's not that it goes wrong the first time. It goes reasonably well the first time, and two years later you have your own code in the critical path of authentication —which is exactly where you don't want your own code—. Every vulnerability published in that territory is yours, and fixing it competes with what actually differentiates your product.

Sticking to the standard changes who carries that burden. OIDC is specified, audited, and implemented by people who do that for a living; second factor, password policy, lockout after failed attempts, and session rotation come already solved. What's left to build is the only thing nobody can hand you ready-made: what each role means in your business and who can touch what.

It's worth getting the vocabulary straight, because almost everything that follows rests on four pieces:

What the application asks for

The portal registers an OIDC provider against the realm and requests three scopes: identifier, email, and profile. Nothing else. It doesn't ask for admin permissions or access to the Keycloak API, because sign-in doesn't need them.

The exchange is the authorization code flow: the application sends the user to the issuer, the issuer authenticates and returns a code, and the application redeems it for a set of tokens. The password never passes through the application at any point, and therefore never through its logs or its database either.

Why the id_token is read and not just userinfo

With the tokens in hand there are two ways to find out who the user is: call the issuer's userinfo endpoint, or decode the identity token you were already given. The portal does the latter, and the former only as a fallback.

The reason is that the claims that matter —the realm roles and the organizations the user belongs to— travel inside the token. Fetching them again is one more network call in the critical path of login, and one more source that can fail. If decoding fails, then the endpoint is queried; if that also fails, sign-in continues with whatever is available.

RBAC at two levels

Access control is RBAC —permissions hang from roles, not users—, but with a twist that isn't optional in a multi-tenant platform: there are roles that describe what you are in the platform and roles that describe what you can do inside a specific space, and they live in different places.

LevelRolesWho decides
Platformsuperadmin, userThe realm, in the token
Customer spacetenant-admin, tenant-viewer, tenant-chat-user, tenant-mcp-userThe application, by membership
The global part comes from the issuer; the per-space part is resolved in the application.

The first level arrives signed in the token: the realm says whether someone is a platform administrator. The second can't come from there, because the same user belongs to several spaces with different roles —they administer their own and only view the one of a customer they work for—. That level is resolved by membership, in the application.

The two product roles are not decorative: one opens the chat and the other enables issuing credentials for external clients that query over the protocol. Separating them lets you give someone access to the conversation without giving them the ability to pull data outside the interface.

And the rule that holds the model together: only whoever administers a space can touch that space's roles. It's not a check on the screen, it's field-level access control — a direct API request hits the same thing as the form.

An organization there, a space here

Keycloak has organizations; the portal has customer spaces. They're the same idea in two systems, and keeping them aligned by hand lasts until the first new customer on a Friday afternoon.

That's why synchronization is an endpoint triggered from the admin itself: it requests an admin token from the management realm, lists the organizations, and creates or updates the corresponding space. The direction is always the same —the issuer commands, the portal obeys—, so there aren't two places to onboard a customer.

// El listado devuelve diez organizaciones si no se le pide otra cosa
const url = `${keycloakUrl}/admin/realms/${realm}/organizations?first=0&max=1000`

That thousand is not a round number by chance: it's the cap I chose to fetch everything in a single request instead of paginating. It works until a realm has more organizations than that, and it's written in the code so whoever finds it knows what to touch.

Matching isn't done by name, but by the identifier Keycloak assigns to each organization, which the space stores as a reference. It looks like a detail and it avoids the classic problem: a customer changes its trade name, someone renames it in the issuer, and at the next sync a duplicate space appears instead of the existing one being updated.

With the identifier as the key, a name change is an update. The process also reports what it created and what it updated, which is the minimum needed to know whether a sync did something or did nothing.

What breaks if the issuer is down

It's worth saying because it's the reasonable objection to centralizing identity: if the issuer goes down, nobody signs in. It's true, and it's the price.

What doesn't go down is what's already inside. Live sessions stay live, and the API tokens users created for integrations don't go through the issuer on every request: the application itself validates them. An issuer outage prevents signing in; it doesn't take down what's running or the automated integrations.

That's why the issuer runs as an independent deployment, with its lifecycle separate from the product. Updating it doesn't drag along an application version, and a failed application deployment doesn't take the identity of the whole platform down with it.

What I don't like about how it stands today

The sync uses admin credentials with a password grant against the management realm. It works and it's the most direct route, but it means the application stores a credential that can do much more than it needs: the right thing would be a service client with permissions scoped to reading organizations.

It's not automatic either: someone has to press the button. For the pace at which I onboard customers it's enough, and I prefer that to a background process that fails silently. When it stops being enough, the natural place is sign-in itself: if the token carries an organization that doesn't exist as a space, create it at that moment.

Keep reading