Skip to main content

security

Every Disposable Email Is A Hole In Your Funnel

Here’s something I noticed while looking at user signups for a product we were running on Keycloak. Healthy registration numbers. Decent activation rate. But a chunk of users just… never came back. Not after day one, not after the welcome email, not after the follow-up. Just gone. When I started digging into the email addresses, it was obvious — temp-mail.org, guerrillamail.com, tempmailo.com. Throwaway addresses. The accounts existed but the people never did. Blocking them isn’t hard in theory. You just need a list of known disposable domains and a place to check against it during registration. The problem is where in Keycloak to put that check. There’s no built-in setting. There’s no toggle in the Admin Console. You can’t just paste a regex somewhere. If you want to validate anything custom during registration, you’re building a Keycloak SPI extension — a Java plugin that hooks into the authentication flow. So I built one. What it does # Two things. Blocks registration with disposable email addresses. If someone tries to sign up with throwaway123@tempmail.com, they get an error on the registration form. Same UX as any other validation error — inline, next to the email field, no page reload. The account never gets created. Exposes a REST endpoint to refresh the domain blocklist. The list of known disposable domains comes from the zliio/disposable library. It’s loaded on startup, but you can hit an endpoint to pull fresh data without restarting Keycloak. That’s it. No database schema changes. No custom theme files. No event listeners to configure separately. Drop the JAR in, wire up the flow, and it works. How it actually works # Keycloak’s authentication system is built around flows — chains of steps that run during login, registration, or any other interaction. Each step is a FormAction. If you want to inject custom logic, you implement that interface and register it as an SPI. This extension does exactly that. EmailDomainValidationFormAction plugs into the registration flow. When someone submits the form, Keycloak calls validate() — it pulls the email field, checks it against the blocklist, and either passes or fails the validation. If it’s a disposable domain, Keycloak shows the error inline. Registration stops. The user’s not in the system. The actual domain check lives in DisposableEmailManager — a singleton wrapping the zliio Disposable instance. One object, shared across all requests. Worth noting: zliio’s validate() returns true for valid (non-disposable) emails, so isDisposableEmail flips that — true means keep out. Small thing, but it tripped me up the first time I read the code. The REST endpoint is a separate SPI — a RealmResourceProvider — registered at: POST /realms/{realm}/brew-disposable-email-resource-provider/refresh-domain-list It’s locked to service accounts with realm-admin role in the realm-management client. Regular user tokens, even admin ones, get a 403. The auth check manually parses the JWT instead of using Keycloak’s built-in role enforcement — it’s checking resourceAccess["realm-management"].roles contains "realm-admin" directly in the token claims. Setting it up # Build the JAR, drop it in Keycloak’s providers/ directory, restart. Full steps are in the README. Requires Java 17, tested on Keycloak 26.2.5. The step that’s easy to miss: installing the JAR does nothing on its own. You have to wire it into a registration flow. Admin Console → your realm → Authentication → Flows Find registration and duplicate it — name it something like registration-with-email-check In the new flow, click Add execution Find “Email Domain Validation” and add it Set its requirement to Required Go to Authentication → Bindings → set Registration flow to your new flow Now every registration attempt goes through the check. Your existing flows aren’t touched, and you can always swap back by changing the binding. Refreshing the domain list # The blocklist loads on startup. To pull an update without restarting, POST to: /realms/{realm}/brew-disposable-email-resource-provider/refresh-domain-list You need a service account token with realm-admin role in realm-management. Regular user tokens get a 403 — even admin ones. Full curl example in the README. Set this up as a weekly cron job. The zliio upstream list grows as new throwaway services pop up — you want to stay current. Things worth knowing before you deploy # The blocklist is in-memory. It lives in the JVM heap, not a database. Keycloak restart clears it back to the startup defaults. If you’re scheduling refreshes, also schedule one on startup — or just accept that you’ll be on the bundled list until the next refresh fires. One manager instance per JVM. DisposableEmailManager is a singleton. In a multi-realm Keycloak setup, a refresh on one realm’s endpoint refreshes the list for all of them. That’s usually fine, but worth knowing. minimizeJar=true is active. The shade plugin strips unused classes to keep the JAR small. If you’re extending this and adding dependencies, verify they don’t get pruned. The minimizeJar flag can be aggressive and it fails silently at runtime. Keycloak SPI classes stay out of the JAR. keycloak-server-spi, keycloak-server-spi-private, keycloak-services — all provided scope. They come from Keycloak’s own classpath at runtime. Never shade them in. If you do, you’ll get classloader conflicts that are annoying to debug. What this doesn’t do # No fuzzy matching, no MX record lookups, no custom domain allowlists, no per-realm config. If someone signs up with a real Gmail account they don’t care about, they get through. This only blocks known disposable domains from the zliio blocklist. If you need something more sophisticated — like blocking domains with no valid MX records, or maintaining your own allowlist — that’s a fork, not a config option. For most use cases though, the zliio list covers what you need. It’s thousands of domains wide and gets updated regularly. It’s not a perfect solution — nothing is. But five minutes of setup to cut out a whole category of junk registrations is a pretty good trade. Found a bug or want a feature? Open an issue on GitLab

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