[{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/backend/","section":"tags","summary":"","title":"backend","type":"tags"},{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/devops/","section":"tags","summary":"","title":"devops","type":"tags"},{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/infrastructure/","section":"tags","summary":"","title":"infrastructure","type":"tags"},{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/security/","section":"tags","summary":"","title":"security","type":"tags"},{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/startup/","section":"tags","summary":"","title":"startup","type":"tags"},{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/","section":"tags","summary":"","title":"tags","type":"tags"},{"content":"You start your startup with one backend service. Simple, lean, single VPS. Works great. But as your product grows, you add more: a caching layer to speed up reads, a message queue to handle async jobs, maybe a microservice to offload payments or image processing. Suddenly you\u0026rsquo;re running five services on one box.\nEach new service gets treated the same way in most tutorials: spin it up, let it listen on 0.0.0.0, point a reverse proxy at it, done. Repeat for the next service.\nWithin minutes of launch, you\u0026rsquo;re getting port scans. Exploit attempts. Bots crawling for management consoles, admin panels, unprotected endpoints. A standard reverse proxy will tell them \u0026ldquo;yes, that admin panel exists, you\u0026rsquo;re just not allowed in\u0026rdquo; via a 403 response. That\u0026rsquo;s a green flag to an attacker.\nSo the obvious question: why expose any of these internal services to the internet at all? Your caching layer doesn\u0026rsquo;t need public access. Your message queue doesn\u0026rsquo;t. Your microservices don\u0026rsquo;t. Only your frontend should face users.\nThe Pattern # You have a backend service that only your frontend needs to talk to. Database, cache, internal API, message queue, whatever. It doesn\u0026rsquo;t need public access. The internet doesn\u0026rsquo;t need to find it.\nThe pattern is simple:\nDecouple the service from the host network. Don\u0026rsquo;t map it to 0.0.0.0:6379. Run it on an internal bridge with no public interfaces. Use Docker Compose, systemd, whatever—just make sure the service can only be reached from localhost or a private network.\nSit behind a programmable edge gateway. OpenResty, Nginx with Lua, or custom reverse proxy. Single point of validation. The gateway is what faces the internet; everything else (backend, database, cache, queues, internal APIs) hides behind it on the private network completely.\nValidate at the gateway. Check headers. Verify domain. Rate limit. IP whitelist admin access. All at the network edge, before requests reach your backend.\nLock down host SSH. Because your VPS is the last place an attacker can reach. Use post-quantum hybrid ciphers if you can. Disable password auth. Lock it down tight.\nThe benefit? Your entire backend infrastructure—services, databases, caches, queues—doesn\u0026rsquo;t announce itself. Scanners hit the gateway, not your infrastructure. They can\u0026rsquo;t even tell what\u0026rsquo;s running behind it. Rate limiters kick in. Admin access requires a whitelisted IP. It looks like there\u0026rsquo;s nothing there to attack.\nIs it bulletproof? No. But it raises the cost of entry massively. Most attackers move on.\nIn Practice # Your database listens on 5432, but only from inside the container. Your Redis cache has weak auth, you never intended for it to face the internet. Your background job worker handles webhooks and orchestrates other services—also internal. Your admin dashboard needs access, but not on its own exposed port; it goes through the gateway on 443 with IP whitelisting. None of these expose themselves directly.\nWhat changes? Everything hides behind the gateway. Frontend requests hit the gateway first. The gateway validates them (auth headers, rate limits, IP checks), then forwards to your backend on the private network. Your backend talks to the database, cache, queues, and internal services. Scanners can\u0026rsquo;t port-scan your database on 5432. They hit the gateway, not your infrastructure. Your admin dashboard isn\u0026rsquo;t exposed on a public port; requests go through the gateway on secure port 443 (https), IP whitelisted. Your background workers process jobs safely on an internal network.\nThe payoff: your internal services don\u0026rsquo;t leak their existence. An attacker sees the gateway. That\u0026rsquo;s it. They can\u0026rsquo;t fingerprint what\u0026rsquo;s running behind it, can\u0026rsquo;t probe for weak auth on your cache. Your admin dashboard sits behind the gateway with IP whitelisting—even if they find the /admin URL, their request hits the gateway, fails the IP check, gets dropped. No 403 \u0026ldquo;access denied\u0026rdquo; that leaks the endpoint exists. Most move on.\nThe Architecture # graph LR A[\"Internet / Attacker\"] --\u003e|Requests| B[\"GatewayPublic-facingValidates, rate limitsTLS termination\"] B --\u003e|Internal only| C[\"Private Network\"] C --\u003e D[\"BackendService\"] D --\u003e E[\"PostgreSQLDatabase\"] D --\u003e F[\"RedisCache\"] D --\u003e G[\"MessageQueue\"] D --\u003e H[\"InternalAPIs\"] style A fill:#ffcccc style B fill:#ffffcc style C fill:#ccffcc style D fill:#ccffcc style E fill:#ccffcc style F fill:#ccffcc style G fill:#ccffcc style H fill:#ccffcc Only the gateway sees the internet. Everything behind it stays invisible.\nWhy This Matters # On a bootstrapped VPS, you\u0026rsquo;re trading off operational simplicity for security. A managed cloud service abstracts this away—AWS, GCP, Azure all have built-in firewalls, DDoS protection, managed services that don\u0026rsquo;t expose anything by default. You pay for it.\nA cheap VPS gives you a clean slate and nothing else. You have to build your own protections. Decoupling your internal services from public access is the foundational move. Everything else—rate limiting, IP whitelisting, SSH hardening flows from there.\nIt\u0026rsquo;s not complicated. It\u0026rsquo;s just a different mental model: the internet should not be able to reach your core infrastructure at all. Everything goes through a gate. The gate does the validation. Core systems stay invisible.\nGoing Deeper # This pattern works at any scale. Single VPS running everything in Docker Compose. Kubernetes cluster with internal services. Multi-region setup. The principle stays the same: decouple, shield, validate at the edge.\nIf you want to see this in action, we built it for a PocketBase based startup stack. Read the full technical walkthrough here. That article covers the specific implementation—Docker Compose setup, OpenResty Lua configuration, Ansible orchestration, post-quantum SSH hardening. But the core pattern is what you just read.\nTry it on your next service that doesn\u0026rsquo;t need public access. You\u0026rsquo;ll be amazed how quiet your infrastructure becomes.\n","date":"16 June 2026","externalUrl":null,"permalink":"/articles/invisible-backend-pattern/","section":"Articles","summary":"You start your startup with one backend service. Simple, lean, single VPS. Works great. But as your product grows, you add more: a caching layer to speed up reads, a message queue to handle async jobs, maybe a microservice to offload payments or image processing. Suddenly you’re running five services on one box.\n","title":"The Invisible Backend: A Pattern for Bootstrapped Infrastructure","type":"articles"},{"content":"","date":"16 June 2026","externalUrl":null,"permalink":"/tags/vps/","section":"tags","summary":"","title":"vps","type":"tags"},{"content":"","date":"15 June 2026","externalUrl":null,"permalink":"/tags/ansible/","section":"tags","summary":"","title":"ansible","type":"tags"},{"content":"","date":"15 June 2026","externalUrl":null,"permalink":"/tags/database/","section":"tags","summary":"","title":"database","type":"tags"},{"content":"","date":"15 June 2026","externalUrl":null,"permalink":"/tags/openresty/","section":"tags","summary":"","title":"openresty","type":"tags"},{"content":"","date":"15 June 2026","externalUrl":null,"permalink":"/tags/pocketbase/","section":"tags","summary":"","title":"pocketbase","type":"tags"},{"content":"When you are launching a startup, the pressure is intense. You need to validate your product idea immediately, which usually means keeping infrastructure lean. There is no budget for massive distributed cloud setups, managed relational databases, or complex Kubernetes clusters. You pick a single reliable virtual machine, choose a fast backend like PocketBase, build your MVP, and push it live.\nBut the moment your server hits the public web, a different countdown begins. Within ten minutes of spinning up a fresh instance, automated script scanners will find your public IP address. Your tiny startup machine is suddenly bombarded with internet background noise: port probes, malicious exploits, and automated bots crawling for database panels. If you run a single-binary backend out in the open, your administration console (/_) is sitting right on the frontline waiting for a brute-force attack.\nA single well-timed exploit or an unmitigated denial-of-service bot could crash your lone server, wiping out your product validation phase before it even starts.\nStandard reverse proxies don\u0026rsquo;t solve this. If you block an unauthorized user, Nginx serves a standard 403 Forbidden page. To an automated bot, a 403 is a massive green flag—it explicitly confirms that a sensitive directory exists, it\u0026rsquo;s just locked.\nWe want our single-box setup to be isolated by default. If someone is not an authorized user or an explicit administrator, infrastructure shouldn\u0026rsquo;t even validate its own existence.\nTo achieve this, we decoupled the data layer entirely from the host network. Put PocketBase inside an isolated internal Docker bridge with zero mapped host ports, dropped a programmable OpenResty edge gateway in front of it, and hardened the host access layer using Ubuntu 26.04’s native hybrid post-quantum SSH ciphers.\nThe entire workflow toggles between an offline local dev VM and the live production VPC using a single, unified Ansible playbook.\nThe Architecture: Keeping the Core Invisible # When your entire startup relies on one machine, resource efficiency is as critical as security. We kept our stack aggressively lean. PocketBase runs inside an Alpine Linux container under a low-privileged system profile. In front of it sits OpenResty, acting as our high-performance programmable shield. Background lifecycle updates for TLS layers are handled quietly by an isolated Certbot instance scheduling daily evaluation checks.\nWe made the explicit architectural decision to enforce local machine access only for the PocketBase backend. By omitting the ports block entirely from the backend service in Docker Compose, the database engine is fundamentally severed from the host machine\u0026rsquo;s public interfaces. It has no idea the public internet exists; it only listens and responds to the internal, isolated bridge network shared exclusively with OpenResty. If the edge gateway doesn\u0026rsquo;t explicitly authorize a packet, it physically cannot reach the database.\nRestructuring the Edge: Subdomains and IP Whitelisting # By default, PocketBase exposes its application framework under a nested route format (https://domain.com/api). We broke this standard behavior by remapping the entire API interface directly to its own clean subdomain root: https://api.domain.com.\nThis isn\u0026rsquo;t just about aesthetic preferences. Moving the API engine to a dedicated subdomain isolates cookies, breaks potential Cross-Site Scripting (XSS) inheritance paths from the main user-facing application frontend, and allows us to apply aggressive, high-utility caching and network boundaries specifically tailored for rapid database traffic.\nAt the same time, we placed a strict IP whitelisting lock over the administrative console (/_). The dashboard pane is an incredibly high-value target for attackers. By restricting it to verified administrator public WAN IPs at the gateway layer, we ensure that even if a zero-day vulnerability is discovered in PocketBase\u0026rsquo;s core login screens tomorrow, an attacker cannot exploit it simply because they cannot route a single byte to the login handler.\nDriving the Shield with Lua \u0026amp; Two-Tier Rate Limiting # To enforce these rules dynamically without crippling Nginx’s blazing speed, we offload the logic to OpenResty’s embedded Lua runtime environment on server boot. Instead of running heavy regex lookups on every single connection, we grab our verified configurations directly from the system variables on startup:\ninit_by_lua_block { global_domain = string.lower(os.getenv(\u0026#34;DOMAIN_NAME\u0026#34;)) global_admin_ips = os.getenv(\u0026#34;ADMIN_TRUSTED_IPS\u0026#34;) or \u0026#34;\u0026#34; } At the same time, we define two separate rate-limiting buckets inside the global space:\n# Public API bucket: 5 requests per second steady-state limit_req_zone $binary_remote_addr zone=api_limit_zone:10m rate=5r/s; # Admin bucket: Strict 1 request per second steady-state limit_req_zone $binary_remote_addr zone=admin_limit_zone:10m rate=1r/s; limit_req_status 429; The matching edge locations intercept incoming requests and process them through a two-step validation chain. First, we enforce a lowercase matching block against ngx.var.host to completely eliminate common Host-Header injection tricks and raw IP scans. Second, we parse the client\u0026rsquo;s connection origin and apply the respective rate limit zone.\nIf either check fails, we invoke Nginx\u0026rsquo;s custom 444 No Response trigger. The proxy drops the TCP stream immediately, sending exactly zero bytes, zero payload headers, and zero server details back to the client. If they breach the rate-limit bucket, they get hit with an explicit HTTP 429 Too Many Requests:\nlocation /_ { access_by_lua_block { if string.lower(ngx.var.host) ~= global_domain then ngx.exit(444) end local ip = ngx.var.remote_addr local allowed = false for addr in string.gmatch(global_admin_ips, \u0026#34;[^,]+\u0026#34;) do if addr:match(\u0026#34;^%s*(.-)%s*$\u0026#34;) == ip then allowed = true break end end if not allowed then ngx.log(ngx.ERR, \u0026#34;Admin probe blocked from IP: \u0026#34;, ip) ngx.exit(444) end } # Enforces the 1r/s limit with a tiny burst window for asset loads limit_req zone=admin_limit_zone burst=3 nodelay; proxy_pass http://pocketbase:8090; } Future-Proofing the Host # Running this layout on Ubuntu 26.04 gives us a major cryptographic advantage right out of the box: OpenSSH v10.2.\nOpenSSH v10.0+ natively promoted hybrid post-quantum key exchange algorithms to the standard system default. By wrapping traditional, battle-tested elliptic curve groups (X25519) inside lattice-based cryptography (ML-KEM-768), our administrative terminal link is completely safe from \u0026ldquo;Harvest Now, Decrypt Later\u0026rdquo; quantum intercept models.\nTo enforce this globally across our cloud nodes, our Ansible playbook modifies the host’s ssh daemon configuration, drops legacy block ciphers, strips out vulnerable classical MAC layers, and locks down terminal handshakes exclusively to post-quantum channels:\n- name: Restrict Host Key Exchanges to Post-Quantum Hybrid Algorithms lineinfile: path: /etc/ssh/sshd_config regexp: \u0026#39;^#?KexAlgorithms\u0026#39; line: \u0026#39;KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com\u0026#39; notify: Restart SSH Daemon Orchestration and the Tag Strategy # The entire lifecycle is coordinated via a centralized Ansible playbook. Because running large host configurations from the beginning gets slow and repetitive over time, we split our setup into modular, highly targeted Tags. This allows us to bypass the entire server setup loop and invoke specific infrastructure tasks using the --tags argument:\n--tags \u0026quot;sync\u0026quot;: Instantly mirrors modifications made inside the local text configuration assets (.env or nginx.conf) and syncs custom server-side JavaScript app hooks safely. --tags \u0026quot;backup\u0026quot;: Triggers an on-demand, timestamped pre-upgrade backup snapshot archive of your SQLite database directory, calculates retention limits, and automatically prunes older archives to protect host disk volume space. --tags \u0026quot;security\u0026quot;: Isolates and enforces the post-quantum SSH host keys, MACs, and symmetric ciphers without touching container states. Closing Thoughts # No single server or setup is entirely bulletproof. If a highly motivated, well-funded adversary with a zero-day exploit targets your infrastructure specifically, a few lines of Lua code won\u0026rsquo;t magically stop them.\nBut that isn\u0026rsquo;t what this layout is trying to solve. Security in a single-server startup is about raising the cost of entry for attackers. By implementing these core best practices, severing your core database from public host interfaces, validating headers at the network edge, and enforcing post-quantum transport safety, you eliminate most of the automated script noise that tanks unhardened boxes.\nRate limiting says, \u0026ldquo;You can try to exploit this endpoint 5 times a second.\u0026rdquo; OpenResty dynamic Lua validation says, \u0026ldquo;If you hit my network interface without matching my clean domain header token, your connection ceases to exist right now.\u0026rdquo;\nIt\u0026rsquo;s a low-overhead, highly performant way to shift the odds drastically in your favour. By keeping your data layer quiet, unsearchable, and securely hidden in the dark, you buy your startup the runway it needs to focus entirely on finding product-market fit.\nBeyond PocketBase # This pattern doesn\u0026rsquo;t require PocketBase. The core idea decouple internal services from public access, validate at the edge, lock down the host - applies to any backend that shouldn\u0026rsquo;t face the internet directly. PostgreSQL, Redis, RabbitMQ, internal APIs, admin dashboards.\nIf you\u0026rsquo;re bootstrapped on a VPS and wondering how to apply this to your specific stack, read about the broader pattern here.\n","date":"15 June 2026","externalUrl":null,"permalink":"/articles/pocketbase-with-openresty-lua-shields/","section":"Articles","summary":"When you are launching a startup, the pressure is intense. You need to validate your product idea immediately, which usually means keeping infrastructure lean. There is no budget for massive distributed cloud setups, managed relational databases, or complex Kubernetes clusters. You pick a single reliable virtual machine, choose a fast backend like PocketBase, build your MVP, and push it live.\n","title":"Securing a Single-Server Startup: PocketBase with OpenResty Lua Shields","type":"articles"},{"content":"","date":"17 May 2026","externalUrl":null,"permalink":"/tags/authentication/","section":"tags","summary":"","title":"authentication","type":"tags"},{"content":"","date":"17 May 2026","externalUrl":null,"permalink":"/tags/disposable-email/","section":"tags","summary":"","title":"disposable-email","type":"tags"},{"content":"Here\u0026rsquo;s something I noticed while looking at user signups for a product we were running on Keycloak.\nHealthy registration numbers. Decent activation rate. But a chunk of users just\u0026hellip; 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.\nBlocking them isn\u0026rsquo;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.\nThere\u0026rsquo;s no built-in setting. There\u0026rsquo;s no toggle in the Admin Console. You can\u0026rsquo;t just paste a regex somewhere. If you want to validate anything custom during registration, you\u0026rsquo;re building a Keycloak SPI extension — a Java plugin that hooks into the authentication flow.\nSo I built one.\nWhat it does # Two things.\nBlocks 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.\nExposes a REST endpoint to refresh the domain blocklist. The list of known disposable domains comes from the zliio/disposable library. It\u0026rsquo;s loaded on startup, but you can hit an endpoint to pull fresh data without restarting Keycloak.\nThat\u0026rsquo;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.\nHow it actually works # Keycloak\u0026rsquo;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.\nThis 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\u0026rsquo;s a disposable domain, Keycloak shows the error inline. Registration stops. The user\u0026rsquo;s not in the system.\nThe actual domain check lives in DisposableEmailManager — a singleton wrapping the zliio Disposable instance. One object, shared across all requests. Worth noting: zliio\u0026rsquo;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.\nThe REST endpoint is a separate SPI — a RealmResourceProvider — registered at:\nPOST /realms/{realm}/brew-disposable-email-resource-provider/refresh-domain-list It\u0026rsquo;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\u0026rsquo;s built-in role enforcement — it\u0026rsquo;s checking resourceAccess[\u0026quot;realm-management\u0026quot;].roles contains \u0026quot;realm-admin\u0026quot; directly in the token claims.\nSetting it up # Build the JAR, drop it in Keycloak\u0026rsquo;s providers/ directory, restart. Full steps are in the README. Requires Java 17, tested on Keycloak 26.2.5.\nThe step that\u0026rsquo;s easy to miss: installing the JAR does nothing on its own. You have to wire it into a registration flow.\nAdmin 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 \u0026ldquo;Email Domain Validation\u0026rdquo; 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\u0026rsquo;t touched, and you can always swap back by changing the binding.\nRefreshing the domain list # The blocklist loads on startup. To pull an update without restarting, POST to:\n/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.\nSet this up as a weekly cron job. The zliio upstream list grows as new throwaway services pop up — you want to stay current.\nThings 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\u0026rsquo;re scheduling refreshes, also schedule one on startup — or just accept that you\u0026rsquo;ll be on the bundled list until the next refresh fires.\nOne manager instance per JVM. DisposableEmailManager is a singleton. In a multi-realm Keycloak setup, a refresh on one realm\u0026rsquo;s endpoint refreshes the list for all of them. That\u0026rsquo;s usually fine, but worth knowing.\nminimizeJar=true is active. The shade plugin strips unused classes to keep the JAR small. If you\u0026rsquo;re extending this and adding dependencies, verify they don\u0026rsquo;t get pruned. The minimizeJar flag can be aggressive and it fails silently at runtime.\nKeycloak SPI classes stay out of the JAR. keycloak-server-spi, keycloak-server-spi-private, keycloak-services — all provided scope. They come from Keycloak\u0026rsquo;s own classpath at runtime. Never shade them in. If you do, you\u0026rsquo;ll get classloader conflicts that are annoying to debug.\nWhat this doesn\u0026rsquo;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\u0026rsquo;t care about, they get through. This only blocks known disposable domains from the zliio blocklist.\nIf you need something more sophisticated — like blocking domains with no valid MX records, or maintaining your own allowlist — that\u0026rsquo;s a fork, not a config option.\nFor most use cases though, the zliio list covers what you need. It\u0026rsquo;s thousands of domains wide and gets updated regularly.\nIt\u0026rsquo;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.\nFound a bug or want a feature? Open an issue on GitLab\nMr Buch / Keycloak / Keycloak Block Disposable Email 0 0 ","date":"17 May 2026","externalUrl":null,"permalink":"/articles/keycloak-block-disposable-email-extension/","section":"Articles","summary":"Here’s something I noticed while looking at user signups for a product we were running on Keycloak.\nHealthy 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.\nBlocking 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.\nThere’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.\nSo I built one.\nWhat it does # Two things.\nBlocks 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.\nExposes 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.\nThat’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.\nHow 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.\nThis 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.\nThe 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.\nThe REST endpoint is a separate SPI — a RealmResourceProvider — registered at:\nPOST /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.\nSetting 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.\nThe step that’s easy to miss: installing the JAR does nothing on its own. You have to wire it into a registration flow.\nAdmin 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.\nRefreshing the domain list # The blocklist loads on startup. To pull an update without restarting, POST to:\n/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.\nSet this up as a weekly cron job. The zliio upstream list grows as new throwaway services pop up — you want to stay current.\nThings 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.\nOne 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.\nminimizeJar=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.\nKeycloak 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.\nWhat 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.\nIf 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.\nFor most use cases though, the zliio list covers what you need. It’s thousands of domains wide and gets updated regularly.\nIt’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.\nFound a bug or want a feature? Open an issue on GitLab\n","title":"Every Disposable Email Is A Hole In Your Funnel","type":"articles"},{"content":"","date":"17 May 2026","externalUrl":null,"permalink":"/tags/iam/","section":"tags","summary":"","title":"iam","type":"tags"},{"content":"","date":"17 May 2026","externalUrl":null,"permalink":"/tags/keycloak/","section":"tags","summary":"","title":"keycloak","type":"tags"},{"content":"","date":"17 May 2026","externalUrl":null,"permalink":"/tags/spam-prevention/","section":"tags","summary":"","title":"spam-prevention","type":"tags"},{"content":"Projects I\u0026rsquo;ve built or contributed to. Mostly scratching my own itches — tools I needed that didn\u0026rsquo;t exist, or existing ones I wanted to improve.\nKeycloak extensions # Standard Keycloak SPIs — drop the JAR in, configure, done.\nBot and brute-force protection # Mr Buch / Keycloak / Keycloak PoW Adds proof-of-work challenges to Keycloak login, registration, and password-reset flows. Uses Argon2 or SHA-256 with IP-adaptive difficulty. Three-layer defense: honeypot detection, solve-time validation, and nonce replay prevention. 0 0 Read the writeup →\nPer-client webhooks # Mr Buch / Keycloak / Keycloak Client Webhook POSTs Keycloak user events to your backend on a per-client basis — no polling, no database queries. Supports registration, login, logout, password reset, and more. Runs async so Keycloak never blocks on your endpoint. 0 0 Read the writeup →\nDisposable email blocking # Mr Buch / Keycloak / Keycloak Block Disposable Email Blocks disposable and throwaway email addresses at Keycloak registration. Checks against a maintained provider list and rejects before the account is created. 0 0 ","date":"16 May 2026","externalUrl":null,"permalink":"/work/","section":"Mr. Buch","summary":"Projects I’ve built or contributed to. Mostly scratching my own itches — tools I needed that didn’t exist, or existing ones I wanted to improve.\nKeycloak extensions # Standard Keycloak SPIs — drop the JAR in, configure, done.\n","title":"Work","type":"page"},{"content":"","date":"14 May 2026","externalUrl":null,"permalink":"/tags/event-driven/","section":"tags","summary":"","title":"event-driven","type":"tags"},{"content":"","date":"14 May 2026","externalUrl":null,"permalink":"/tags/integration/","section":"tags","summary":"","title":"integration","type":"tags"},{"content":"Here\u0026rsquo;s a situation I\u0026rsquo;ve been in more times than I\u0026rsquo;d like to admit.\nYou 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\u0026rsquo;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.\nBecause Keycloak knew. Nobody else did.\nSo 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\u0026rsquo;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\u0026hellip; duplicate the registration logic in your backend and keep both in sync manually? I\u0026rsquo;ve seen this in production. It\u0026rsquo;s exactly as bad as it sounds.\nNone of these are good options. They\u0026rsquo;re just different ways to be annoyed later.\nWhat I actually wanted was simple: when something happens in Keycloak, POST it to my backend. That\u0026rsquo;s it. I don\u0026rsquo;t want to poll. I don\u0026rsquo;t want to touch the database. I just want an event, a payload, and an endpoint to send it to.\nSo I built it.\nKeycloak Webhook 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.\nHow it actually works # 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\u0026rsquo;t wait. The user doesn\u0026rsquo;t wait. If your endpoint is slow, fine. If it\u0026rsquo;s down, it retries three times with a short backoff (1s, 2s, 3s) and logs what happened. Then life goes on.\nThe payload you get looks like this:\n{ \u0026#34;type\u0026#34;: \u0026#34;REGISTER\u0026#34;, \u0026#34;user_id\u0026#34;: \u0026#34;a1b2c3d4-e5f6-7890-abcd-ef1234567890\u0026#34;, \u0026#34;user_name\u0026#34;: \u0026#34;john.doe\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;john.doe@example.com\u0026#34;, \u0026#34;first_name\u0026#34;: \u0026#34;John\u0026#34;, \u0026#34;last_name\u0026#34;: \u0026#34;Doe\u0026#34;, \u0026#34;email_verified\u0026#34;: false, \u0026#34;created_timestamp\u0026#34;: 1747353600000, \u0026#34;user_ip\u0026#34;: \u0026#34;203.0.113.42\u0026#34;, \u0026#34;user_agent\u0026#34;: \u0026#34;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\u0026#34;, \u0026#34;delete_by_admin\u0026#34;: false, \u0026#34;user_roles\u0026#34;: [ \u0026#34;default-roles-myrealm\u0026#34;, \u0026#34;offline_access\u0026#34; ], \u0026#34;organizations\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;org-uuid-1234\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Acme Corp\u0026#34;, \u0026#34;alias\u0026#34;: \u0026#34;acme-corp\u0026#34; } ], \u0026#34;attributes\u0026#34;: { \u0026#34;phone_number\u0026#34;: [\u0026#34;+1-555-0100\u0026#34;], \u0026#34;company\u0026#34;: [\u0026#34;Acme Corp\u0026#34;], \u0026#34;job_title\u0026#34;: [\u0026#34;Engineer\u0026#34;] }, \u0026#34;realm\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;a3f8c2d1-1234-5678-abcd-000000000001\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;myrealm\u0026#34;, \u0026#34;display_name\u0026#34;: \u0026#34;My Application Realm\u0026#34; } } Supported events: REGISTER, REGISTER_ERROR, LOGIN, LOGOUT, RESET_PASSWORD, VERIFY_EMAIL, UPDATE_EMAIL, DELETE_ACCOUNT.\nREGISTER_ERROR is the weird one — registration failed, so there\u0026rsquo;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.\nSetting it up # Build the JAR:\ngit clone \u0026lt;repo-url\u0026gt; cd keycloak-webhook mvn clean package Mount it into Keycloak:\ndocker run -p 8080:8080 \\ -v ./target/keycloak-webhook.jar:/opt/keycloak/providers/keycloak-webhook.jar \\ quay.io/keycloak/keycloak:26.6 start-dev It\u0026rsquo;s a Keycloak SPI — auto-registers on startup, no theme files, no extra config.\nNow the step everyone skips: go to Admin console → your realm → Realm Settings → Events, and add brew-event-webhook to the Event Listeners field. Save. Do this for every realm you care about.\nThe JAR alone does nothing until you activate it here. I know because I\u0026rsquo;ve forgotten this myself.\nThen configure the webhook endpoint on whichever client you want. There\u0026rsquo;s no Attributes tab in the UI for this — you\u0026rsquo;ll need the Keycloak Admin API. You can get the client UUID from Admin console → Clients → your client → the URL in your browser.\nFor the token, don\u0026rsquo;t use your admin user credentials. Instead, create a dedicated client for this:\nAdmin console → Clients → Create client Enable Service account roles (under Capability config) Go to that client → Service accounts roles tab → Assign role → filter by realm-management → add manage-clients Then get a token from that client:\ncurl -X POST \\ \u0026#34;https://your-keycloak/realms/{realm}/protocol/openid-connect/token\u0026#34; \\ -d \u0026#34;grant_type=client_credentials\u0026#34; \\ -d \u0026#34;client_id={your-service-client-id}\u0026#34; \\ -d \u0026#34;client_secret={your-service-client-secret}\u0026#34; Now fetch the full client representation first — the PUT replaces the entire object, so you need the existing data:\ncurl -X GET \\ \u0026#34;https://your-keycloak/admin/realms/{realm}/clients/{client-uuid}\u0026#34; \\ -H \u0026#34;Authorization: Bearer {access_token}\u0026#34; Take that JSON, add (or merge) your webhook attributes into it, and PUT it back:\ncurl -X PUT \\ \u0026#34;https://your-keycloak/admin/realms/{realm}/clients/{client-uuid}\u0026#34; \\ -H \u0026#34;Authorization: Bearer {access_token}\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ ...existing client JSON..., \u0026#34;attributes\u0026#34;: { ...existing attributes..., \u0026#34;api.url\u0026#34;: \u0026#34;https://yourapi.com/webhooks/keycloak\u0026#34;, \u0026#34;api.key\u0026#34;: \u0026#34;your-secret-token\u0026#34; } }\u0026#39; Don\u0026rsquo;t skip the GET step. Sending a partial body to the PUT will wipe out existing client config.\nThat\u0026rsquo;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.\nThe config, all in one place # Attribute Required Description api.url Yes Your webhook endpoint api.key Yes Bearer token, sent in the Authorization header disable.autologin No true to prevent Keycloak from auto-logging in users after registration trusted.proxy.count No Number of reverse proxies in front of Keycloak (default: 1). If client IPs are coming out wrong, this is probably why What happens when your backend is down # Short answer: nothing bad. Keycloak keeps running, users keep getting logged in, and you get log lines that look like this:\nWARN: 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 After three failures, the event is gone. There\u0026rsquo;s no queue, no database, no replay mechanism. This is a deliberate tradeoff — adding durable queuing would mean adding infrastructure, and most people don\u0026rsquo;t need it. For syncing a CRM or sending a welcome email, losing one webhook during a 3am outage is acceptable.\nIf you genuinely can\u0026rsquo;t lose events, pair this with Keycloak\u0026rsquo;s built-in event log as a backup, or replay from the admin API after recovery. But in practice, I\u0026rsquo;ve found that the retry behavior covers most real outage scenarios — by the third attempt, you\u0026rsquo;re probably back up.\nA note on async # 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\u0026rsquo;s a bad time.\nEvery 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\u0026rsquo;s the whole reason the extension is useful in production.\nWhat this doesn\u0026rsquo;t do # No payload transformation, no event filtering, no guaranteed delivery, no replay.\nIf 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.\nFound a bug or want a feature? Open an issue on GitLab.\nMr Buch / Keycloak / Keycloak Client Webhook 0 0 ","date":"14 May 2026","externalUrl":null,"permalink":"/articles/keycloak-webhook-extension/","section":"Articles","summary":"Here’s a situation I’ve been in more times than I’d like to admit.\nYou 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.\nBecause Keycloak knew. Nobody else did.\nSo 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.\nNone of these are good options. They’re just different ways to be annoyed later.\nWhat 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.\nSo I built it.\nKeycloak Webhook 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.\nHow it actually works # 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.\nThe payload you get looks like this:\n{ \"type\": \"REGISTER\", \"user_id\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\", \"user_name\": \"john.doe\", \"email\": \"john.doe@example.com\", \"first_name\": \"John\", \"last_name\": \"Doe\", \"email_verified\": false, \"created_timestamp\": 1747353600000, \"user_ip\": \"203.0.113.42\", \"user_agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36\", \"delete_by_admin\": false, \"user_roles\": [ \"default-roles-myrealm\", \"offline_access\" ], \"organizations\": [ { \"id\": \"org-uuid-1234\", \"name\": \"Acme Corp\", \"alias\": \"acme-corp\" } ], \"attributes\": { \"phone_number\": [\"+1-555-0100\"], \"company\": [\"Acme Corp\"], \"job_title\": [\"Engineer\"] }, \"realm\": { \"id\": \"a3f8c2d1-1234-5678-abcd-000000000001\", \"name\": \"myrealm\", \"display_name\": \"My Application Realm\" } } Supported events: REGISTER, REGISTER_ERROR, LOGIN, LOGOUT, RESET_PASSWORD, VERIFY_EMAIL, UPDATE_EMAIL, DELETE_ACCOUNT.\nREGISTER_ERROR 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.\nSetting it up # Build the JAR:\ngit clone \u003crepo-url\u003e cd keycloak-webhook mvn clean package Mount it into Keycloak:\ndocker run -p 8080:8080 \\ -v ./target/keycloak-webhook.jar:/opt/keycloak/providers/keycloak-webhook.jar \\ quay.io/keycloak/keycloak:26.6 start-dev It’s a Keycloak SPI — auto-registers on startup, no theme files, no extra config.\nNow the step everyone skips: go to Admin console → your realm → Realm Settings → Events, and add brew-event-webhook to the Event Listeners field. Save. Do this for every realm you care about.\nThe JAR alone does nothing until you activate it here. I know because I’ve forgotten this myself.\nThen 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.\nFor the token, don’t use your admin user credentials. Instead, create a dedicated client for this:\nAdmin console → Clients → Create client Enable Service account roles (under Capability config) Go to that client → Service accounts roles tab → Assign role → filter by realm-management → add manage-clients Then get a token from that client:\ncurl -X POST \\ \"https://your-keycloak/realms/{realm}/protocol/openid-connect/token\" \\ -d \"grant_type=client_credentials\" \\ -d \"client_id={your-service-client-id}\" \\ -d \"client_secret={your-service-client-secret}\" Now fetch the full client representation first — the PUT replaces the entire object, so you need the existing data:\ncurl -X GET \\ \"https://your-keycloak/admin/realms/{realm}/clients/{client-uuid}\" \\ -H \"Authorization: Bearer {access_token}\" Take that JSON, add (or merge) your webhook attributes into it, and PUT it back:\ncurl -X PUT \\ \"https://your-keycloak/admin/realms/{realm}/clients/{client-uuid}\" \\ -H \"Authorization: Bearer {access_token}\" \\ -H \"Content-Type: application/json\" \\ -d '{ ...existing client JSON..., \"attributes\": { ...existing attributes..., \"api.url\": \"https://yourapi.com/webhooks/keycloak\", \"api.key\": \"your-secret-token\" } }' Don’t skip the GET step. Sending a partial body to the PUT will wipe out existing client config.\nThat’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.\nThe config, all in one place # Attribute Required Description api.url Yes Your webhook endpoint api.key Yes Bearer token, sent in the Authorization header disable.autologin No true to prevent Keycloak from auto-logging in users after registration trusted.proxy.count No Number of reverse proxies in front of Keycloak (default: 1). If client IPs are coming out wrong, this is probably why What happens when your backend is down # Short answer: nothing bad. Keycloak keeps running, users keep getting logged in, and you get log lines that look like this:\nWARN: 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 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.\nIf 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.\nA note on async # 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.\nEvery 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.\nWhat this doesn’t do # No payload transformation, no event filtering, no guaranteed delivery, no replay.\nIf 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.\nFound a bug or want a feature? Open an issue on GitLab.\n","title":"Keycloak Knows. Why Doesn't The Rest Of Your Stack?","type":"articles"},{"content":"","date":"14 May 2026","externalUrl":null,"permalink":"/tags/webhooks/","section":"tags","summary":"","title":"webhooks","type":"tags"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/tags/argon2/","section":"tags","summary":"","title":"argon2","type":"tags"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/tags/bot-protection/","section":"tags","summary":"","title":"bot-protection","type":"tags"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/tags/proof-of-work/","section":"tags","summary":"","title":"proof-of-work","type":"tags"},{"content":"I got tired of watching our login endpoint get hammered by bots. Credential stuffing, brute force, the usual nonsense. Rate limiting helps, but it\u0026rsquo;s blunt — one script kiddie from a datacenter and suddenly your whole office can\u0026rsquo;t log in because they\u0026rsquo;re all on the same IP.\nThat\u0026rsquo;s why I built a Keycloak extension that does PoW (proof of work) challenges. Sounds complicated, but it\u0026rsquo;s actually pretty elegant: make bots solve a math problem before they get to the password field. Real users barely notice. Attackers\u0026rsquo; ROI goes to zero ( not literally ;-) ).\nThe interesting part? I went with Argon2id as the default algorithm, not SHA-256. That decision deserves explaining because it\u0026rsquo;s not what most people think of when they hear \u0026ldquo;PoW.\u0026rdquo;\nThe Problem With Just SHA-256 # Everyone knows SHA-256 PoW. Bitcoin uses it. It\u0026rsquo;s simple: find a nonce where SHA256(data + nonce) has N leading zero bits. Done.\nBut here\u0026rsquo;s the thing: SHA-256 is cheap to parallelize. If you\u0026rsquo;ve got a GPU (and attackers do), you can compute billions of hashes per second. Rent a cloud GPU for an hour, hammer someone\u0026rsquo;s login endpoint with thousands of SHA-256 challenges, suddenly 5% of leaked passwords work.\nI didn\u0026rsquo;t want that.\nWhy Argon2id Changed My Mind # The reason is memory hardness — it requires a bunch of RAM per computation, not just CPU.\nWhen 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.\nReal 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\u0026rsquo;s GPU, which crushes SHA-256 25× over, barely breaks even on Argon2id. It\u0026rsquo;s not about being slow — it\u0026rsquo;s about being GPU-resistant.\nThat\u0026rsquo;s why it\u0026rsquo;s the default.\nHow It Actually Works # There are three layers:\nHoneypot field — There\u0026rsquo;s a hidden input in the form. If it\u0026rsquo;s filled, they\u0026rsquo;re a bot. Silent reject, no hash work. Saves us CPU against dumb scrapers.\nSolve-time validation — Every challenge gets timestamped. If someone submits in 100ms, they solved it offline. Reject. Minimum solve time is configurable.\nThe actual hash — Browser does SHA-256 (fast, just for UI responsiveness), but the server verifies with Argon2id (expensive, actual security gate). You can\u0026rsquo;t bypass the server cost.\nPlus, 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\u0026rsquo;s cost-per-attempt skyrockets.\nConfig 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.\nIf 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.\nHigh-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.\nThere\u0026rsquo;s also SHA-256 fallback if you\u0026rsquo;re doing 1,000+ logins per minute and profiling shows Argon2 is a real bottleneck. But honestly, unless you\u0026rsquo;re a massive site, Argon2 is the right call.\nSetting It Up # Grab it from GitLab:\ngit clone https://gitlab.com/mrbuch/keycloak/keycloak-pow.git cd keycloak-pow ./mvnw clean package Then drop the JAR into Keycloak:\ndocker 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\u0026rsquo;s a Keycloak SPI, so it just\u0026hellip; registers itself. Works on login, registration, password reset. No theme files to copy.\nWant to tweak settings? Environment variables:\nPOW_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.\nWhy This Matters # Rate limiting is defensive. Proof of Work makes the attack uneconomical. There\u0026rsquo;s a difference.\nRate limiting says \u0026ldquo;you can try 10 times per minute.\u0026rdquo; Attackers just spin up more IPs.\nArgon2id PoW says \u0026ldquo;every attempt costs you 100-400ms of CPU and 16MB of RAM.\u0026rdquo; Distributed across a botnet, suddenly you\u0026rsquo;re looking at thousands of dollars in cloud costs to test 100k passwords. Or you just\u0026hellip; don\u0026rsquo;t.\nOne 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\u0026rsquo;re serious about protecting auth, memory hardness matters.\nIt\u0026rsquo;s not about being paranoid. It\u0026rsquo;s about not making yourself the path of least resistance.\nQuestions? Issues? Hit up the GitLab repo.\nMr Buch / Keycloak / Keycloak PoW 0 0 ","date":"7 May 2026","externalUrl":null,"permalink":"/articles/keycloak-pow-extension/","section":"Articles","summary":"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.\nThat’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 ;-) ).\nThe 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.”\nThe 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.\nBut 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.\nI didn’t want that.\nWhy Argon2id Changed My Mind # The reason is memory hardness — it requires a bunch of RAM per computation, not just CPU.\nWhen 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.\nReal 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.\nThat’s why it’s the default.\nHow It Actually Works # There are three layers:\nHoneypot 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.\nSolve-time validation — Every challenge gets timestamped. If someone submits in 100ms, they solved it offline. Reject. Minimum solve time is configurable.\nThe 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.\nPlus, 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.\nConfig 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.\nIf 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.\nHigh-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.\nThere’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.\nSetting It Up # Grab it from GitLab:\ngit clone https://gitlab.com/mrbuch/keycloak/keycloak-pow.git cd keycloak-pow ./mvnw clean package Then drop the JAR into Keycloak:\ndocker 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.\nWant to tweak settings? Environment variables:\nPOW_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.\nWhy This Matters # Rate limiting is defensive. Proof of Work makes the attack uneconomical. There’s a difference.\nRate limiting says “you can try 10 times per minute.” Attackers just spin up more IPs.\nArgon2id 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.\nOne 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.\nIt’s not about being paranoid. It’s about not making yourself the path of least resistance.\nQuestions? Issues? Hit up the GitLab repo.\n","title":"Protecting Keycloak Auth with Proof of Work","type":"articles"},{"content":"","date":"7 May 2026","externalUrl":null,"permalink":"/articles/","section":"Articles","summary":"","title":"Articles","type":"articles"},{"content":"I help founders turn ideas into real products — building teams, making technical decisions, and occasionally getting my hands dirty in code. I\u0026rsquo;ve been doing this for over 20 years — and I still find it genuinely fun.\nHow I got here # I started my career fixing networks and building SCADA systems. From there I moved into Flash development (yes, Flash — it was cool once), then enterprise software for Deutsche Bank, then ad-tech, then my own companies.\nEach jump taught me something different: how infrastructure breaks under load, how big companies ship slowly, how fast startups have to move to survive. I carry all of it.\nIn 2013, I co-founded Bidstalk with a friend — a white-label demand-side platform for advertisers. We built it lean, got traction, and the company was later acquired by AppLift. That was my first real taste of building something from zero that others found worth buying.\nWhat I\u0026rsquo;ve built # I founded BinaryBrew to do what I love most — help founders move fast. We work as an embedded technical partner: strategy, architecture, and shipping real product. No hand-holding, no bloated process.\nBefore that, I co-founded IQM with friends and spent four years building a full programmatic advertising stack from the ground up — teams, products, infrastructure and all.\nTwenty-plus years in, I still get a kick out of the early stage — when everything is messy and nothing is decided yet.\nWhat I\u0026rsquo;m good at # I\u0026rsquo;m most useful when there\u0026rsquo;s a hard problem and no playbook.\nI help founders make the right decisions early — technical and business — before they become expensive to undo. That means architecture, yes, but also pricing models, product tradeoffs, team structure, and what to build vs. buy vs. skip entirely.\nI\u0026rsquo;ve built and led teams across multiple time zones, set up processes that actually work, and turned ambiguous ideas into real products. I also think about availability and scale from day one — building systems that hold up as products grow, not just as they launch.\nA few things worth mentioning # Occasional open source contributor — mostly scratching my own itches. I\u0026rsquo;ve hired and managed offshore development centers — I know how to make distributed teams actually function Won \u0026ldquo;Most Viral Hack\u0026rdquo; at Yahoo Hack Day India (2007) — back when hackathons were still weird and fun Want to work together? # I typically engage:\nFractional CTO — ongoing technical leadership without a full-time hire MVP build — embedded team, real product, fixed scope Architecture review — one-time deep dive, decisions you can act on Advisory — lightweight ongoing input, no execution ","date":"7 May 2026","externalUrl":null,"permalink":"/about/","section":"Mr. Buch","summary":"I help founders turn ideas into real products — building teams, making technical decisions, and occasionally getting my hands dirty in code. I’ve been doing this for over 20 years — and I still find it genuinely fun.\n","title":"Hey, I'm Chintan Buch","type":"page"},{"content":"I help founders design and build products that don\u0026rsquo;t fall apart as they scale — making the right technical calls early, de-spaghettifying architecture, and getting teams to actually ship. I\u0026rsquo;ve done this across ad-tech, fintech, and SaaS — at every stage from \u0026ldquo;idea on a napkin\u0026rdquo; to post-acquisition.\nHere you\u0026rsquo;ll find technical writing, open-source projects, and the occasional opinion on what matters — and everything in between.\n","date":"7 May 2026","externalUrl":null,"permalink":"/","section":"Mr. Buch","summary":"I help founders design and build products that don’t fall apart as they scale — making the right technical calls early, de-spaghettifying architecture, and getting teams to actually ship. I’ve done this across ad-tech, fintech, and SaaS — at every stage from “idea on a napkin” to post-acquisition.\n","title":"Mr. Buch","type":"page"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"categories","summary":"","title":"categories","type":"categories"}]