<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>bot-protection on Mr. Buch</title><link>https://mrbu.ch/tags/bot-protection/</link><description>Recent content in bot-protection on Mr. Buch</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>c@mrbu.ch (Chintan Buch)</managingEditor><webMaster>c@mrbu.ch (Chintan Buch)</webMaster><copyright>© 2026 Chintan Buch</copyright><lastBuildDate>Thu, 07 May 2026 05:41:52 +0530</lastBuildDate><atom:link href="https://mrbu.ch/tags/bot-protection/index.xml" rel="self" type="application/rss+xml"/><item><title>Protecting Keycloak Auth with Proof of Work</title><link>https://mrbu.ch/articles/keycloak-pow-extension/</link><pubDate>Thu, 07 May 2026 05:41:52 +0530</pubDate><author>c@mrbu.ch (Chintan Buch)</author><guid>https://mrbu.ch/articles/keycloak-pow-extension/</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&amp;rsquo;s blunt — one script kiddie from a datacenter and suddenly your whole office can&amp;rsquo;t log in because they&amp;rsquo;re all on the same IP.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why I built a Keycloak extension that does PoW (proof of work) challenges. Sounds complicated, but it&amp;rsquo;s actually pretty elegant: make bots solve a math problem before they get to the password field. Real users barely notice. Attackers&amp;rsquo; 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&amp;rsquo;s not what most people think of when they hear &amp;ldquo;PoW.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;The Problem With Just SHA-256
 &lt;div id="the-problem-with-just-sha-256" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-problem-with-just-sha-256" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h2&gt;
&lt;p&gt;Everyone knows SHA-256 PoW. Bitcoin uses it. It&amp;rsquo;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&amp;rsquo;s the thing: SHA-256 is &lt;em&gt;cheap&lt;/em&gt; to parallelize. If you&amp;rsquo;ve got a GPU (and attackers do), you can compute billions of hashes per second. Rent a cloud GPU for an hour, hammer someone&amp;rsquo;s login endpoint with thousands of SHA-256 challenges, suddenly 5% of leaked passwords work.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want that.&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;Why Argon2id Changed My Mind
 &lt;div id="why-argon2id-changed-my-mind" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-argon2id-changed-my-mind" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&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&amp;rsquo;s GPU, which crushes SHA-256 25× over, barely breaks even on Argon2id. It&amp;rsquo;s not about being slow — it&amp;rsquo;s about being &lt;em&gt;GPU-resistant&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why it&amp;rsquo;s the default.&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;How It Actually Works
 &lt;div id="how-it-actually-works" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-it-actually-works" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&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&amp;rsquo;s a hidden input in the form. If it&amp;rsquo;s filled, they&amp;rsquo;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&amp;rsquo;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&amp;rsquo;s cost-per-attempt skyrockets.&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;Config Examples (Because Real Numbers Help)
 &lt;div id="config-examples-because-real-numbers-help" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#config-examples-because-real-numbers-help" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h2&gt;

&lt;h3 class="relative group"&gt;Basic Setup
 &lt;div id="basic-setup" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#basic-setup" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h3&gt;
&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;hash_algorithm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;argon2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_base_difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_memory_kb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16384 # 16 MB&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_max_difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_rate_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10 # per 60 sec&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&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 class="relative group"&gt;If You Actually Care (Finance, Healthcare)
 &lt;div id="if-you-actually-care-finance-healthcare" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#if-you-actually-care-finance-healthcare" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h3&gt;
&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;hash_algorithm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;argon2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_base_difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_memory_kb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;32768 # 32 MB&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_max_difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_rate_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5 # stricter&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&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 class="relative group"&gt;High-Traffic Site (If Argon2 Feels Too Heavy)
 &lt;div id="high-traffic-site-if-argon2-feels-too-heavy" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#high-traffic-site-if-argon2-feels-too-heavy" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h3&gt;
&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;hash_algorithm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;argon2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_base_difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_memory_kb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8192 # 8 MB instead&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_max_difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;argon2_rate_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;20 # more forgiving&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Still GPU-resistant, but lighter. ~50ms base cost.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also SHA-256 fallback if you&amp;rsquo;re doing 1,000+ logins per minute and profiling shows Argon2 is a real bottleneck. But honestly, unless you&amp;rsquo;re a massive site, Argon2 is the right call.&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;Setting It Up
 &lt;div id="setting-it-up" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#setting-it-up" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h2&gt;
&lt;p&gt;Grab it from GitLab:&lt;/p&gt;
&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://gitlab.com/mrbuch/keycloak/keycloak-pow.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; keycloak-pow
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./mvnw clean package&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Then drop the JAR into Keycloak:&lt;/p&gt;
&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker run -p 8080:8080 &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -v ./target/keycloak-pow.jar:/opt/keycloak/providers/keycloak-pow.jar &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; quay.io/keycloak/keycloak:26.6.1 start-dev&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;It&amp;rsquo;s a Keycloak SPI, so it just&amp;hellip; 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;/p&gt;
&lt;div class="highlight-wrapper"&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;POW_HASH_ALGORITHM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;argon2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;POW_ARGON2_MEMORY_KB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;16384&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;POW_ARGON2_BASE_DIFFICULTY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ... etc&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Or go to the Keycloak UI and edit per-flow. Your call.&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;Why This Matters
 &lt;div id="why-this-matters" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-this-matters" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&lt;/h2&gt;
&lt;p&gt;Rate limiting is defensive. Proof of Work makes the attack &lt;em&gt;uneconomical&lt;/em&gt;. There&amp;rsquo;s a difference.&lt;/p&gt;
&lt;p&gt;Rate limiting says &amp;ldquo;you can try 10 times per minute.&amp;rdquo; Attackers just spin up more IPs.&lt;/p&gt;
&lt;p&gt;Argon2id PoW says &amp;ldquo;every attempt costs you 100-400ms of CPU and 16MB of RAM.&amp;rdquo; Distributed across a botnet, suddenly you&amp;rsquo;re looking at thousands of dollars in cloud costs to test 100k passwords. Or you just&amp;hellip; don&amp;rsquo;t.&lt;/p&gt;
&lt;hr&gt;

&lt;h2 class="relative group"&gt;One More Thing
 &lt;div id="one-more-thing" class="anchor"&gt;&lt;/div&gt;
 
 &lt;span
 class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none"&gt;
 &lt;a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#one-more-thing" aria-label="Anchor"&gt;#&lt;/a&gt;
 &lt;/span&gt;
 
&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&amp;rsquo;re serious about protecting auth, memory hardness matters.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not about being paranoid. It&amp;rsquo;s about not making yourself the path of least resistance.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Questions? Issues? &lt;a href="https://gitlab.com/mrbuch/keycloak/keycloak-pow" target="_blank" rel="noreferrer"&gt;Hit up the GitLab repo&lt;/a&gt;.&lt;/p&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://mrbu.ch/articles/keycloak-pow-extension/cover.jpg"/></item></channel></rss>