<?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>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>
