A propos Compétences Expérience Services Blog Contact

Quand Keycloak et ta base ne sont plus d'accord sur qui existe When Keycloak and your database disagree about who exists

Un utilisateur peut exister dans ta base et pas dans ton fournisseur d'identité. Rien ne crashe. Tout échoue en silence.

TL;DR : un compte à moitié supprimé casse quatre parcours d'un coup, sans une seule erreur utile. Ton code avale le 404 de l'IdP exprès, pour bloquer l'énumération de comptes. La réparation finit en SQL direct, parce que l'API admin de Keycloak ignore l'id que tu lui donnes. La prévention tient en deux choses : un ordre de suppression, et un invariant testé.

Cet article est pour les devs qui ont Keycloak, ou n'importe quel IdP, à côté d'une table users applicative.

Le contexte

Un IdP, un fournisseur d'identité, est le service qui authentifie tes utilisateurs. Keycloak est l'un des IdP open source les plus déployés.

Sur mon SaaS perso, Keycloak porte l'authentification. Ma base applicative porte le métier : profils, tenants, contrats. Le même utilisateur vit donc dans deux magasins, reliés par un UUID.

Je conçois aussi du SSO multi-région pour une plateforme santé à plus de 25 millions d'utilisateurs. Les échelles changent. Cette classe de bug, non.

La dérive : un nettoyage partiel

Un matin de mai, un compte de test refuse de s'inscrire. En base : une ligne users avec son UUID. Dans Keycloak : rien.

La cause était un nettoyage partiel. Un script de purge d'environnement de test avait supprimé des comptes dans un magasin sans toucher l'autre. Le garde-fou ne protégeait qu'un domaine de mail, pas le second.

La première passe d'assainissement a trouvé 539 comptes orphelins. Ce n'est pas un cas d'école. C'est une population.

Le diagnostic tient en deux requêtes :

-- Côté base applicative
SELECT id, email FROM users WHERE email = '[email protected]';

-- Côté Keycloak
SELECT id, email FROM user_entity WHERE email = '[email protected]';

-- Une ligne d'un côté, rien de l'autre : dérive.

Quatre symptômes, aucun ne pointe la cause

Un compte dans cet état casse quatre parcours en même temps.

Le magic link répond 200 et n'envoie rien. Un magic link est un lien de connexion envoyé par mail, sans mot de passe.

L'inscription répond 500 au premier essai. Puis 409 à tous les suivants, pour toujours.

L'acceptation d'invitation ne retrouve plus l'utilisateur.

Et les lignes métier restent accrochées à un UUID que plus personne ne peut charger.

Quatre symptômes, quatre tickets différents. Aucune stack trace commune. C'est le pire type d'incident : celui qui ne ressemble pas à un incident.

Pourquoi c'est invisible : ta propre sécurité

Pourquoi aucune erreur ne remonte ? Parce que je l'ai voulu.

L'énumération de comptes consiste à deviner quels emails ont un compte chez toi, en observant les réponses de l'API. Pour la bloquer, l'API doit répondre la même chose que le compte existe ou non.

Mon service avale donc le 404 de Keycloak, volontairement. Le magic link répond 200 dans tous les cas. La sécurité que j'ai posée a détruit mon observabilité.

La règle que j'en tire : masque l'erreur dans la réponse HTTP, jamais dans tes logs.

// Même réponse pour le client, que le compte existe ou non.
// La dérive, elle, doit rester visible pour toi.
user, err := idp.UserByEmail(ctx, email)
if errors.Is(err, ErrUserNotFound) {
    slog.Warn("idp user missing", "flow", "magic_link")
    metrics.IdpUserMissing.Inc()
    respondOK(w) // anti-énumération : on ne dit rien dehors
    return
}

Un 404 avalé sans métrique est une dette d'incident. Tu la rembourseras au pire moment.

La réparation : l'API admin ignore ton id

Pour réparer, il faut recréer l'utilisateur dans Keycloak avec le même UUID. Les lignes métier y sont accrochées.

Et là, surprise. L'API admin de Keycloak ignore le champ id que tu envoies à la création d'un utilisateur. Elle génère le sien. Ton UUID est irrécupérable par la porte officielle.

Il reste la porte de service : le SQL, directement dans les tables de Keycloak.

-- Recréer l'utilisateur en gardant l'UUID de la base applicative
INSERT INTO user_entity (id, realm_id, username, email, enabled, ...)
VALUES ('le-meme-uuid-que-ta-base', ...);

INSERT INTO user_attribute (user_id, name, value)
VALUES ('le-meme-uuid-que-ta-base', 'tenant_id', ...);

INSERT INTO user_role_mapping (user_id, role_id)
VALUES ('le-meme-uuid-que-ta-base', ...);

Écrire dans la base de Keycloak est un dernier recours, pas une habitude. Fais-le à froid. Vérifie ensuite avec kcadm, puis rejoue le parcours complet. Chez moi : demande de magic link, jeton généré, mail reçu.

La prévention : un ordre et un invariant

La réparation ne vaut rien sans la prévention. Trois changements ont suivi l'incident.

La suppression d'un compte est devenue une saga. Une saga est une suite d'étapes ordonnées, où chaque étape ne part que si la précédente a réussi. Chez moi : Keycloak d'abord, la base ensuite. Plus aucun script ne touche un seul des deux magasins.

Un endpoint d'admin liste la dérive : les UUID présents d'un côté et absents de l'autre. Ce qui était invisible devient une liste.

Et chaque purge se termine par un comptage des deux côtés. Si les nombres divergent, la purge a menti.

La règle générale : la cohérence entre deux magasins d'identité est un invariant à tester. Pas une convention à espérer.

La checklist anti-dérive

Si tu as un IdP et une table users, passe cette liste. Elle m'aurait évité un incident et 539 orphelins.

  • Un seul chemin de suppression : la saga, jamais un script direct sur un seul magasin
  • Chaque étape de la saga est conditionnée au succès de la précédente
  • L'anti-énumération masque la réponse HTTP, jamais les logs ni les métriques
  • Une métrique compte les 404 IdP avalés, avec une alerte dessus
  • Un endpoint ou un job liste la dérive entre l'IdP et ta base
  • Un test d'intégration crée, supprime, puis compte les deux côtés
  • La procédure de réparation préserve l'UUID, et elle est écrite avant l'incident

Ce qu'il faut retenir

Deux magasins d'identité, c'est un système distribué. Avec ses promesses, et ses mensonges.

Le pire état n'est pas la panne. C'est le compte à moitié vivant, qui échoue en silence, protégé par ta propre sécurité.

Teste l'invariant. Compte les deux côtés. Et garde une trace interne de chaque 404 que tu avales.

Ton IdP et ta base racontent des histoires différentes en production ? Parlons-en.

A user can exist in your database and not in your identity provider. Nothing crashes. Everything fails silently.

TL;DR: a half-deleted account breaks four flows at once, without one useful error. Your code swallows the IdP's 404 on purpose, to block account enumeration. The repair ends in direct SQL, because the Keycloak admin API ignores the id you send. Prevention comes down to two things: a deletion order, and a tested invariant.

This article is for developers running Keycloak, or any IdP, next to an application users table.

The setup

An IdP, an identity provider, is the service that authenticates your users. Keycloak is one of the most deployed open source IdPs.

On my personal SaaS, Keycloak owns authentication. My application database owns the business: profiles, tenants, contracts. The same user lives in two stores, linked by a UUID.

I also design multi-region SSO for a healthcare platform with 25M+ users. The scale changes. This class of bug does not.

The drift: a partial cleanup

One morning in May, a test account refuses to sign up. In the database: a users row with its UUID. In Keycloak: nothing.

The cause was a partial cleanup. A test-environment purge script had deleted accounts in one store without touching the other. The safety guard only protected one mail domain, not the second one.

The first repair pass found 539 orphan accounts. That is not an edge case. That is a population.

The diagnosis fits in two queries:

-- Application database side
SELECT id, email FROM users WHERE email = '[email protected]';

-- Keycloak side
SELECT id, email FROM user_entity WHERE email = '[email protected]';

-- A row on one side, nothing on the other: drift.

Four symptoms, none points at the cause

An account in this state breaks four flows at the same time.

The magic link returns 200 and sends nothing. A magic link is a login link sent by email, no password involved.

Registration returns 500 on the first try. Then 409 on every retry, forever.

Invitation acceptance can no longer resolve the user.

And business rows stay attached to a UUID nobody can load anymore.

Four symptoms, four different tickets. No shared stack trace. It is the worst kind of incident: the kind that does not look like one.

Why it is invisible: your own security

Why does no error surface? Because I wanted it that way.

Account enumeration means guessing which emails have an account, by watching API responses. To block it, the API must answer the same thing whether the account exists or not.

So my service swallows Keycloak's 404, deliberately. The magic link returns 200 in every case. The security I added destroyed my observability.

The rule I keep: hide the error in the HTTP response, never in your logs.

// Same response for the client, account or no account.
// The drift itself must stay visible to you.
user, err := idp.UserByEmail(ctx, email)
if errors.Is(err, ErrUserNotFound) {
    slog.Warn("idp user missing", "flow", "magic_link")
    metrics.IdpUserMissing.Inc()
    respondOK(w) // anti-enumeration: say nothing outside
    return
}

A swallowed 404 with no metric is incident debt. You will pay it back at the worst time.

The repair: the admin API ignores your id

To repair, you must recreate the user in Keycloak with the same UUID. The business rows hang from it.

Then, surprise. The Keycloak admin API ignores the id field you send when creating a user. It generates its own. Your UUID cannot come back through the official door.

That leaves the service door: SQL, straight into Keycloak's tables.

-- Recreate the user, keeping the application database UUID
INSERT INTO user_entity (id, realm_id, username, email, enabled, ...)
VALUES ('same-uuid-as-your-db', ...);

INSERT INTO user_attribute (user_id, name, value)
VALUES ('same-uuid-as-your-db', 'tenant_id', ...);

INSERT INTO user_role_mapping (user_id, role_id)
VALUES ('same-uuid-as-your-db', ...);

Writing into Keycloak's database is a last resort, not a habit. Do it cold. Verify with kcadm afterwards, then replay the full flow. In my case: magic link requested, token generated, email received.

Prevention: an order and an invariant

The repair is worth nothing without prevention. Three changes followed the incident.

Account deletion became a saga. A saga is a sequence of ordered steps, where each step only runs if the previous one succeeded. In my case: Keycloak first, the database second. No script touches a single store anymore.

An admin endpoint lists the drift: UUIDs present on one side and missing on the other. What was invisible becomes a list.

And every purge ends with a count on both sides. If the numbers disagree, the purge lied.

The general rule: consistency between two identity stores is an invariant to test. Not a convention to hope for.

The anti-drift checklist

If you run an IdP and a users table, walk this list. It would have saved me one incident and 539 orphans.

  • One deletion path only: the saga, never a direct script on a single store
  • Each saga step is gated on the previous step's success
  • Anti-enumeration masks the HTTP response, never the logs or metrics
  • A metric counts swallowed IdP 404s, with an alert on it
  • An endpoint or job lists the drift between the IdP and your database
  • An integration test creates, deletes, then counts both sides
  • The repair procedure preserves the UUID, and it is written before the incident

What to remember

Two identity stores make a distributed system. With its promises, and its lies.

The worst state is not the outage. It is the half-alive account, failing silently, protected by your own security.

Test the invariant. Count both sides. And keep an internal trace of every 404 you swallow.

Your IdP and your database telling different stories in production? Let's talk.