DEV Community

Mr. Buch
Mr. Buch

Posted on

Protecting Keycloak Auth with Proof of Work

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.

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 ;-) ).

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


The Problem With Just SHA-256

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

But here's the thing: SHA-256 is cheap 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.

I didn't want that.


Why Argon2id Changed My Mind

The reason is memory hardness — it requires a bunch of RAM per computation, not just CPU.

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.

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 GPU-resistant.

That's why it's the default.


How It Actually Works

There are three layers:

Honeypot field — 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.

Solve-time validation — Every challenge gets timestamped. If someone submits in 100ms, they solved it offline. Reject. Minimum solve time is configurable.

The actual hash — 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.

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.


Config Examples (Because Real Numbers Help)

Basic Setup

hash_algorithm = argon2
argon2_base_difficulty = 1
argon2_memory_kb = 16384      # 16 MB
argon2_iterations = 1
argon2_max_difficulty = 4
argon2_rate_threshold = 10    # per 60 sec
Enter fullscreen mode Exit fullscreen mode

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.

If You Actually Care (Finance, Healthcare)

hash_algorithm = argon2
argon2_base_difficulty = 2
argon2_memory_kb = 32768      # 32 MB
argon2_iterations = 2
argon2_max_difficulty = 8
argon2_rate_threshold = 5     # stricter
Enter fullscreen mode Exit fullscreen mode

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.

High-Traffic Site (If Argon2 Feels Too Heavy)

hash_algorithm = argon2
argon2_base_difficulty = 1
argon2_memory_kb = 8192       # 8 MB instead
argon2_iterations = 1
argon2_max_difficulty = 3
argon2_rate_threshold = 20    # more forgiving
Enter fullscreen mode Exit fullscreen mode

Still GPU-resistant, but lighter. ~50ms base cost.

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.


Setting It Up

Grab it from GitLab:

git clone https://gitlab.com/mrbuch/keycloak/keycloak-pow.git
cd keycloak-pow
./mvnw clean package
Enter fullscreen mode Exit fullscreen mode

Then drop the JAR into Keycloak:

docker run -p 8080:8080 \
  -v ./target/keycloak-pow.jar:/opt/keycloak/providers/keycloak-pow.jar \
  quay.io/keycloak/keycloak:26.6.1 start-dev
Enter fullscreen mode Exit fullscreen mode

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

Want to tweak settings? Environment variables:

POW_HASH_ALGORITHM=argon2
POW_ARGON2_MEMORY_KB=16384
POW_ARGON2_BASE_DIFFICULTY=1
# ... etc
Enter fullscreen mode Exit fullscreen mode

Or go to the Keycloak UI and edit per-flow. Your call.


Why This Matters

Rate limiting is defensive. Proof of Work makes the attack uneconomical. There's a difference.

Rate limiting says "you can try 10 times per minute." Attackers just spin up more IPs.

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.


One More Thing

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.

It's not about being paranoid. It's about not making yourself the path of least resistance.


Questions? Issues? Hit up the GitLab repo.

Top comments (0)