<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mr. Buch</title>
    <description>The latest articles on DEV Community by Mr. Buch (@mr_buch).</description>
    <link>https://dev.to/mr_buch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3915905%2Fbbfb881d-f811-4a0f-ac95-76094954c845.png</url>
      <title>DEV Community: Mr. Buch</title>
      <link>https://dev.to/mr_buch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mr_buch"/>
    <language>en</language>
    <item>
      <title>Keycloak Knows. Why Doesn't The Rest Of Your Stack?</title>
      <dc:creator>Mr. Buch</dc:creator>
      <pubDate>Thu, 14 May 2026 20:49:21 +0000</pubDate>
      <link>https://dev.to/mr_buch/keycloak-knows-why-doesnt-the-rest-of-your-stack-4ikj</link>
      <guid>https://dev.to/mr_buch/keycloak-knows-why-doesnt-the-rest-of-your-stack-4ikj</guid>
      <description>&lt;p&gt;Here's a situation I've been in more times than I'd like to admit.&lt;/p&gt;

&lt;p&gt;You set up Keycloak. It works great. Users register, log in, reset passwords — all handled. You move on to building the actual product. Then three weeks later, someone asks why the CRM doesn't have half the users in it. Or why the billing system is charging people who deleted their accounts six months ago. Or why the welcome email never went out.&lt;/p&gt;

&lt;p&gt;Because Keycloak knew. Nobody else did.&lt;/p&gt;

&lt;p&gt;So you go looking for the clean solution. Maybe you poll the admin API every few minutes? Sure, if you enjoy stale data and hammering your auth server for no reason. Maybe you query Keycloak's database directly? Works great until the next upgrade shuffles the schema and you spend a weekend figuring out why everything broke. Maybe you just... duplicate the registration logic in your backend and keep both in sync manually? I've seen this in production. It's exactly as bad as it sounds.&lt;/p&gt;

&lt;p&gt;None of these are good options. They're just different ways to be annoyed later.&lt;/p&gt;




&lt;p&gt;What I actually wanted was simple: when something happens in Keycloak, POST it to my backend. That's it. I don't want to poll. I don't want to touch the database. I just want an event, a payload, and an endpoint to send it to.&lt;/p&gt;

&lt;p&gt;So I built it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keycloak Webhook&lt;/strong&gt; is a small Keycloak extension — drop the JAR in, add two fields to your client config, and you start getting HTTP POSTs every time a user does something. Registration, login, logout, password reset, email change, account deletion. Your backend just handles the request and moves on.&lt;/p&gt;




&lt;h2&gt;
  
  
  How it actually works
&lt;/h2&gt;

&lt;p&gt;The extension registers itself as a Keycloak event listener. When a user event fires, it looks up the webhook config on that client, then hands off the HTTP POST to a background thread. Keycloak doesn't wait. The user doesn't wait. If your endpoint is slow, fine. If it's down, it retries three times with a short backoff (1s, 2s, 3s) and logs what happened. Then life goes on.&lt;/p&gt;

&lt;p&gt;The payload you get looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REGISTER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4-e5f6-7890-abcd-ef1234567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"john.doe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"john.doe@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"first_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"John"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"last_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Doe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1747353600000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"203.0.113.42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delete_by_admin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"default-roles-myrealm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"offline_access"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"organizations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org-uuid-1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"alias"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme-corp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phone_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"+1-555-0100"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"job_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Engineer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"realm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a3f8c2d1-1234-5678-abcd-000000000001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myrealm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"display_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Application Realm"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supported events: &lt;code&gt;REGISTER&lt;/code&gt;, &lt;code&gt;REGISTER_ERROR&lt;/code&gt;, &lt;code&gt;LOGIN&lt;/code&gt;, &lt;code&gt;LOGOUT&lt;/code&gt;, &lt;code&gt;RESET_PASSWORD&lt;/code&gt;, &lt;code&gt;VERIFY_EMAIL&lt;/code&gt;, &lt;code&gt;UPDATE_EMAIL&lt;/code&gt;, &lt;code&gt;DELETE_ACCOUNT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;REGISTER_ERROR&lt;/code&gt; is the weird one — registration failed, so there's no user in Keycloak yet, but we still send what we have (email, name from the form, error details). Useful for tracking failed signups or debugging onboarding drop-off.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting it up
&lt;/h2&gt;

&lt;p&gt;Build the JAR:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &amp;lt;repo-url&amp;gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;keycloak-webhook
mvn clean package
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount it into Keycloak:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; ./target/keycloak-webhook.jar:/opt/keycloak/providers/keycloak-webhook.jar &lt;span class="se"&gt;\&lt;/span&gt;
  quay.io/keycloak/keycloak:26.6 start-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a Keycloak SPI — auto-registers on startup, no theme files, no extra config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now the step everyone skips:&lt;/strong&gt; go to Admin console → your realm → &lt;strong&gt;Realm Settings → Events&lt;/strong&gt;, and add &lt;code&gt;brew-event-webhook&lt;/code&gt; to the Event Listeners field. Save. Do this for every realm you care about.&lt;/p&gt;

&lt;p&gt;The JAR alone does nothing until you activate it here. I know because I've forgotten this myself.&lt;/p&gt;

&lt;p&gt;Then configure the webhook endpoint on whichever client you want. There's no Attributes tab in the UI for this — you'll need the Keycloak Admin API. You can get the client UUID from Admin console → Clients → your client → the URL in your browser.&lt;/p&gt;

&lt;p&gt;For the token, don't use your admin user credentials. Instead, create a dedicated client for this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Admin console → Clients → &lt;strong&gt;Create client&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Enable &lt;strong&gt;Service account roles&lt;/strong&gt; (under Capability config)&lt;/li&gt;
&lt;li&gt;Go to that client → &lt;strong&gt;Service accounts roles&lt;/strong&gt; tab → &lt;strong&gt;Assign role&lt;/strong&gt; → filter by &lt;code&gt;realm-management&lt;/code&gt; → add &lt;strong&gt;manage-clients&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then get a token from that client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://your-keycloak/realms/{realm}/protocol/openid-connect/token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=client_credentials"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id={your-service-client-id}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret={your-service-client-secret}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now fetch the full client representation first — the PUT replaces the entire object, so you need the existing data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://your-keycloak/admin/realms/{realm}/clients/{client-uuid}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take that JSON, add (or merge) your webhook attributes into it, and PUT it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://your-keycloak/admin/realms/{realm}/clients/{client-uuid}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {access_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    ...existing client JSON...,
    "attributes": {
      ...existing attributes...,
      "api.url": "https://yourapi.com/webhooks/keycloak",
      "api.key": "your-secret-token"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't skip the GET step. Sending a partial body to the PUT will wipe out existing client config.&lt;/p&gt;

&lt;p&gt;That's the whole setup. Different clients can point to completely different endpoints with different secrets — a web app and mobile app posting to separate backends, each with their own auth key. No global config file, no redeploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The config, all in one place
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Required&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api.url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Your webhook endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api.key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Bearer token, sent in the Authorization header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;disable.autologin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; to prevent Keycloak from auto-logging in users after registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trusted.proxy.count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Number of reverse proxies in front of Keycloak (default: 1). If client IPs are coming out wrong, this is probably why&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What happens when your backend is down
&lt;/h2&gt;

&lt;p&gt;Short answer: nothing bad. Keycloak keeps running, users keep getting logged in, and you get log lines that look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;WARN: Webhook request failed (attempt 1/3): 500 Internal Server Error
WARN: Webhook request failed (attempt 2/3): 500 Internal Server Error
WARN: Webhook request failed (attempt 3/3): 500 Internal Server Error
WARN: Max retries exceeded for webhook. Event: REGISTER, User: testuser
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After three failures, the event is gone. There's no queue, no database, no replay mechanism. This is a deliberate tradeoff — adding durable queuing would mean adding infrastructure, and most people don't need it. For syncing a CRM or sending a welcome email, losing one webhook during a 3am outage is acceptable.&lt;/p&gt;

&lt;p&gt;If you genuinely can't lose events, pair this with Keycloak's built-in event log as a backup, or replay from the admin API after recovery. But in practice, I've found that the retry behavior covers most real outage scenarios — by the third attempt, you're probably back up.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on async
&lt;/h2&gt;

&lt;p&gt;Keycloak event listeners are synchronous. If I block on the HTTP POST, I block Keycloak — the user stares at a spinner while we wait for your endpoint to respond. That's a bad time.&lt;/p&gt;

&lt;p&gt;Every webhook runs on a background thread pool instead. Your endpoint can take 10 seconds, throw a 503, or be unreachable. The user already logged in. Keycloak already moved on. This is non-negotiable — it's the whole reason the extension is useful in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;No payload transformation, no event filtering, no guaranteed delivery, no replay.&lt;/p&gt;

&lt;p&gt;If you only want REGISTER events, filter in your handler. If you need to reshape the payload for your CRM, do it in your backend. The extension does one thing — get events out of Keycloak and into your hands — and it does it without making itself complicated to operate.&lt;/p&gt;




&lt;p&gt;Found a bug or want a feature? &lt;a href="https://gitlab.com/mrbuch/keycloak/keycloak-client-webhook" rel="noopener noreferrer"&gt;Open an issue on GitLab&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>webhook</category>
      <category>java</category>
      <category>developer</category>
    </item>
    <item>
      <title>Protecting Keycloak Auth with Proof of Work</title>
      <dc:creator>Mr. Buch</dc:creator>
      <pubDate>Wed, 06 May 2026 20:58:58 +0000</pubDate>
      <link>https://dev.to/mr_buch/protecting-keycloak-auth-with-proof-of-work-2i4d</link>
      <guid>https://dev.to/mr_buch/protecting-keycloak-auth-with-proof-of-work-2i4d</guid>
      <description>&lt;p&gt;I got tired of watching our login endpoint get hammered by bots. Credential stuffing, brute force, the usual nonsense. Rate limiting helps, but it's blunt — one script kiddie from a datacenter and suddenly your whole office can't log in because they're all on the same IP.&lt;/p&gt;

&lt;p&gt;That's why I built a Keycloak extension that does PoW (proof of work) challenges. Sounds complicated, but it's actually pretty elegant: make bots solve a math problem before they get to the password field. Real users barely notice. Attackers' ROI goes to zero ( not literally ;-) ).&lt;/p&gt;

&lt;p&gt;The interesting part? I went with &lt;strong&gt;Argon2id&lt;/strong&gt; as the default algorithm, not SHA-256. That decision deserves explaining because it's not what most people think of when they hear "PoW."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With Just SHA-256
&lt;/h2&gt;

&lt;p&gt;Everyone knows SHA-256 PoW. Bitcoin uses it. It's simple: find a nonce where &lt;code&gt;SHA256(data + nonce)&lt;/code&gt; has N leading zero bits. Done.&lt;/p&gt;

&lt;p&gt;But here's the thing: SHA-256 is &lt;em&gt;cheap&lt;/em&gt; to parallelize. If you've got a GPU (and attackers do), you can compute billions of hashes per second. Rent a cloud GPU for an hour, hammer someone's login endpoint with thousands of SHA-256 challenges, suddenly 5% of leaked passwords work.&lt;/p&gt;

&lt;p&gt;I didn't want that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Argon2id Changed My Mind
&lt;/h2&gt;

&lt;p&gt;The reason is memory hardness — it requires a bunch of RAM per computation, not just CPU.&lt;/p&gt;

&lt;p&gt;When you run Argon2id with 16 MB of memory per challenge (default), suddenly renting a GPU cluster becomes stupid. GPUs have tons of compute but memory bandwidth is bottlenecked. Your CPU on a $200 server does almost as well as a $10k GPU because the limiting factor shifts from compute to memory latency.&lt;/p&gt;

&lt;p&gt;Real numbers: a CPU does ~5 SHA-256 PoW challenges per second (16-bit difficulty). Same CPU running Argon2id (16 MB, 1 iteration) does ~0.2 challenges per second. But an attacker's GPU, which crushes SHA-256 25× over, barely breaks even on Argon2id. It's not about being slow — it's about being &lt;em&gt;GPU-resistant&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's why it's the default.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Actually Works
&lt;/h2&gt;

&lt;p&gt;There are three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honeypot field&lt;/strong&gt; — There's a hidden input in the form. If it's filled, they're a bot. Silent reject, no hash work. Saves us CPU against dumb scrapers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solve-time validation&lt;/strong&gt; — Every challenge gets timestamped. If someone submits in 100ms, they solved it offline. Reject. Minimum solve time is configurable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual hash&lt;/strong&gt; — Browser does SHA-256 (fast, just for UI responsiveness), but the server verifies with Argon2id (expensive, actual security gate). You can't bypass the server cost.&lt;/p&gt;

&lt;p&gt;Plus, difficulty ramps up per IP. First few logins from an IP? Base difficulty (100ms on Argon2id). Try 50 times in a minute? Difficulty jumps. Try 100 times? It keeps climbing. Attacker's cost-per-attempt skyrockets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Config Examples (Because Real Numbers Help)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Basic Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;hash_algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;argon2&lt;/span&gt;
&lt;span class="py"&gt;argon2_base_difficulty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;argon2_memory_kb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16384      # 16 MB&lt;/span&gt;
&lt;span class="py"&gt;argon2_iterations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;argon2_max_difficulty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;argon2_rate_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10    # per 60 sec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Legitimate login takes ~100ms extra. An attacker hammering from one IP hits difficulty=4 after ~50 requests. At that point, solving 1,000 challenges takes 5+ minutes. Not worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  If You Actually Care (Finance, Healthcare)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;hash_algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;argon2&lt;/span&gt;
&lt;span class="py"&gt;argon2_base_difficulty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;argon2_memory_kb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;32768      # 32 MB&lt;/span&gt;
&lt;span class="py"&gt;argon2_iterations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;argon2_max_difficulty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;span class="py"&gt;argon2_rate_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5     # stricter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base load is 400ms. Rate-scaled attacks hit 1.6 seconds per attempt pretty quick. Someone trying 1,000 logins is looking at 25+ minutes of compute.&lt;/p&gt;

&lt;h3&gt;
  
  
  High-Traffic Site (If Argon2 Feels Too Heavy)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;hash_algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;argon2&lt;/span&gt;
&lt;span class="py"&gt;argon2_base_difficulty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;argon2_memory_kb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8192       # 8 MB instead&lt;/span&gt;
&lt;span class="py"&gt;argon2_iterations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;argon2_max_difficulty&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;argon2_rate_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;20    # more forgiving&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still GPU-resistant, but lighter. ~50ms base cost.&lt;/p&gt;

&lt;p&gt;There's also SHA-256 fallback if you're doing 1,000+ logins per minute and profiling shows Argon2 is a real bottleneck. But honestly, unless you're a massive site, Argon2 is the right call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;p&gt;Grab it from GitLab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://gitlab.com/mrbuch/keycloak/keycloak-pow.git
&lt;span class="nb"&gt;cd &lt;/span&gt;keycloak-pow
./mvnw clean package
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then drop the JAR into Keycloak:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; ./target/keycloak-pow.jar:/opt/keycloak/providers/keycloak-pow.jar &lt;span class="se"&gt;\&lt;/span&gt;
  quay.io/keycloak/keycloak:26.6.1 start-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a Keycloak SPI, so it just... registers itself. Works on login, registration, password reset. No theme files to copy.&lt;/p&gt;

&lt;p&gt;Want to tweak settings? Environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;POW_HASH_ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;argon2
&lt;span class="nv"&gt;POW_ARGON2_MEMORY_KB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16384
&lt;span class="nv"&gt;POW_ARGON2_BASE_DIFFICULTY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="c"&gt;# ... etc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or go to the Keycloak UI and edit per-flow. Your call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Rate limiting is defensive. Proof of Work makes the attack &lt;em&gt;uneconomical&lt;/em&gt;. There's a difference.&lt;/p&gt;

&lt;p&gt;Rate limiting says "you can try 10 times per minute." Attackers just spin up more IPs.&lt;/p&gt;

&lt;p&gt;Argon2id PoW says "every attempt costs you 100-400ms of CPU and 16MB of RAM." Distributed across a botnet, suddenly you're looking at thousands of dollars in cloud costs to test 100k passwords. Or you just... don't.&lt;/p&gt;




&lt;h2&gt;
  
  
  One More Thing
&lt;/h2&gt;

&lt;p&gt;I went with Argon2 because GPU-resistant proof of work is becoming table stakes. SHA-256 PoW made sense in 2015. In 2026, if you're serious about protecting auth, memory hardness matters.&lt;/p&gt;

&lt;p&gt;It's not about being paranoid. It's about not making yourself the path of least resistance.&lt;/p&gt;




&lt;p&gt;Questions? Issues? &lt;a href="https://gitlab.com/mrbuch/keycloak/keycloak-pow" rel="noopener noreferrer"&gt;Hit up the GitLab repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>security</category>
      <category>pow</category>
      <category>antispam</category>
    </item>
  </channel>
</rss>
