<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vladislav Rajtmajer</title>
    <description>The latest articles on DEV Community by Vladislav Rajtmajer (@vladislav_rajtmajer_18389).</description>
    <link>https://dev.to/vladislav_rajtmajer_18389</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3924188%2F192ce8c9-2d27-42f0-91d4-7887a4878f46.jpeg</url>
      <title>DEV Community: Vladislav Rajtmajer</title>
      <link>https://dev.to/vladislav_rajtmajer_18389</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vladislav_rajtmajer_18389"/>
    <language>en</language>
    <item>
      <title>Laravel RateLimiter and a race condition</title>
      <dc:creator>Vladislav Rajtmajer</dc:creator>
      <pubDate>Thu, 14 May 2026 11:38:20 +0000</pubDate>
      <link>https://dev.to/vladislav_rajtmajer_18389/laravel-ratelimiter-and-a-race-condition-4b55</link>
      <guid>https://dev.to/vladislav_rajtmajer_18389/laravel-ratelimiter-and-a-race-condition-4b55</guid>
      <description>&lt;p&gt;One of the manual rate-limiting patterns shown in the Laravel docs (under &lt;a href="https://laravel.com/docs/12.x/rate-limiting#manually-incrementing-attempts" rel="noopener noreferrer"&gt;Manually Incrementing Attempts&lt;/a&gt;) looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tooManyAttempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send-message:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Too many attempts!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send-message:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Send message...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works fine. Right up until someone hits an endpoint capped at 5 requests per minute with &lt;strong&gt;100 concurrent requests&lt;/strong&gt;. Then all 100 get through.&lt;/p&gt;

&lt;p&gt;I ran into this race condition while building rate limiting for &lt;a href="https://captchaapi.eu" rel="noopener noreferrer"&gt;captchaapi.eu&lt;/a&gt;, a PoW CAPTCHA API. Credit goes to @_newtonjob, who nailed it in 280 characters in &lt;a href="https://x.com/_newtonjob/status/2039031311076139489" rel="noopener noreferrer"&gt;a post on X&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your Ratelimiting logic works until someone fires 100 concurrent requests on an endpoint that should be limited to 5 requests per minute.&lt;br&gt;
The fix: Ensure you/your agents also check the incremented count returned by &lt;code&gt;RateLimiter::hit()&lt;/code&gt; and that it doesn't exceed the max attempts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(Note: &lt;code&gt;hit()&lt;/code&gt; and &lt;code&gt;increment()&lt;/code&gt; are aliases — &lt;code&gt;hit()&lt;/code&gt; is literally a one-line wrapper that calls &lt;code&gt;increment()&lt;/code&gt;. The Laravel docs example used &lt;code&gt;hit()&lt;/code&gt; in 8.x and 9.x, then switched to &lt;code&gt;increment()&lt;/code&gt; from 10.x onward, but both still work and have identical behavior.)&lt;/p&gt;

&lt;p&gt;Here's why it's a problem, the one-line fix, and what I took away from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is it a problem?
&lt;/h2&gt;

&lt;p&gt;Walk through what happens on a single request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;tooManyAttempts()&lt;/code&gt; reads the current count from cache&lt;/li&gt;
&lt;li&gt;Compares it against &lt;code&gt;$maxAttempts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Returns &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;false&lt;/code&gt;, the code calls &lt;code&gt;increment()&lt;/code&gt;, which bumps the count&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's &lt;strong&gt;two independent cache calls&lt;/strong&gt;. Between step 1 and step 4 there's a window, usually a few microseconds, where another request can read the same stale value, pass the check, and increment too.&lt;/p&gt;

&lt;p&gt;At 100 concurrent requests it happens at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request 1 reads count = 0, passes the check (0 &amp;lt; 5), calls &lt;code&gt;increment()&lt;/code&gt; → count = 1&lt;/li&gt;
&lt;li&gt;Request 2 reads count = 0 (at the same instant), passes the check, calls &lt;code&gt;increment()&lt;/code&gt; → count = 2&lt;/li&gt;
&lt;li&gt;...and so on for all 100&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The counter ends up at 100, but &lt;strong&gt;all 100 requests already ran&lt;/strong&gt;, and your backend just processed 100x the work you wanted. If the endpoint does something expensive (a PoW challenge, AI inference, an external API call), you just paid for 100 operations instead of 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the increment is atomic but the check isn't
&lt;/h2&gt;

&lt;p&gt;If you crack open Laravel's source (&lt;code&gt;Illuminate\Cache\RateLimiter::increment()&lt;/code&gt; in 12.x):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$decaySeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cleanRateLimiterKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':timer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;availableAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decaySeconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$decaySeconds&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$added&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutSerializationOrCompression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$decaySeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;hit()&lt;/code&gt; is just an alias for &lt;code&gt;increment()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$decaySeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$decaySeconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is &lt;code&gt;$this-&amp;gt;cache-&amp;gt;increment($key, $amount)&lt;/code&gt;. That's an &lt;strong&gt;atomic operation&lt;/strong&gt; in the cache backend.&lt;/p&gt;

&lt;p&gt;I use Redis in captchaapi.eu, where it maps to &lt;code&gt;INCR&lt;/code&gt; (or &lt;code&gt;INCRBY&lt;/code&gt;), one of the oldest, most battle-tested commands in Redis. It's atomic at the single-key write level: no two concurrent requests will read the same value, and each one gets a unique incremented result back. Memcached has an equivalent &lt;code&gt;incr&lt;/code&gt; with the same guarantees.&lt;/p&gt;

&lt;p&gt;Here's the key thing: &lt;strong&gt;&lt;code&gt;increment()&lt;/code&gt; returns the count after the increment.&lt;/strong&gt; The return value is atomic, deterministic, and unique for every concurrent caller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// With 100 concurrent requests you get return values 1, 2, 3, ..., 100&lt;/span&gt;
&lt;span class="c1"&gt;// (in random order, but each value exactly once)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tooManyAttempts()&lt;/code&gt;, on the other hand, is a separate read. It can return a stale value, and the gap between that read and the next write is your race window.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one increment, check the return value
&lt;/h2&gt;

&lt;p&gt;Drop the two-step pattern (&lt;code&gt;tooManyAttempts&lt;/code&gt; → &lt;code&gt;increment&lt;/code&gt;) and do it in one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'send-message:'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Too many attempts!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Send message...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now with 100 concurrent requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each request gets a &lt;strong&gt;unique&lt;/strong&gt; count after the increment&lt;/li&gt;
&lt;li&gt;The first 5 get values 1–5 and pass&lt;/li&gt;
&lt;li&gt;The remaining 95 get values 6–100 and get rejected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No window, no race. Redis's atomic increment is the single source of truth, and &lt;code&gt;increment()&lt;/code&gt; gives you that truth directly.&lt;/p&gt;

&lt;p&gt;One subtle thing worth pointing out: in the original pattern, the increment happens &lt;em&gt;after&lt;/em&gt; the check, so any overshoot stays in the counter ("the counter shows 6 even though we didn't want to allow request #6"). In the new pattern you increment every time and check the return value, so the counter might show 100. That's fine, because anything over the limit got rejected. The counter readout looks the same, but the security model is stricter.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;hit()&lt;/code&gt; vs. &lt;code&gt;increment()&lt;/code&gt;: which one?
&lt;/h2&gt;

&lt;p&gt;They do the same thing. &lt;code&gt;hit()&lt;/code&gt; is literally &lt;code&gt;function hit($key, $decay) { return $this-&amp;gt;increment($key, $decay); }&lt;/code&gt;. The current Laravel docs (10.x+) show &lt;code&gt;increment()&lt;/code&gt; in the manual-incrementing example; older versions (8.x, 9.x) used &lt;code&gt;hit()&lt;/code&gt;. Both work, pick whichever reads better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hit()&lt;/code&gt;&lt;/strong&gt; for "register one event," the natural fit for rate limiting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;increment()&lt;/code&gt;&lt;/strong&gt; when you want to emphasize the atomic-counter aspect, or bump by more than 1 via the &lt;code&gt;amount:&lt;/code&gt; parameter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In captchaapi.eu I went with &lt;code&gt;increment()&lt;/code&gt; because it matches the current docs and makes it obvious I care about the return value, not the side effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this doesn't help
&lt;/h2&gt;

&lt;p&gt;One honest limitation: this protects against the race within a &lt;strong&gt;single Redis instance&lt;/strong&gt; (or a Redis cluster, where each key lives on one shard). If you had Redis split across regions without coordination and an attacker fired requests at every region, &lt;code&gt;INCR&lt;/code&gt;'s atomicity wouldn't save you. You'd get 100 requests &lt;em&gt;per region&lt;/em&gt;, times N regions.&lt;/p&gt;

&lt;p&gt;For captchaapi.eu this is plenty, because the whole app runs against a single Redis (Hetzner Nuremberg). For multi-region distributed rate limiting you'd need something like a sliding window log or a token bucket with a centralized source of truth. Different topic.&lt;/p&gt;

&lt;p&gt;The other limit: this is a fix for counter-level races. If an attacker rotates IPs (botnet, residential proxy), no per-IP rate limit will stop them. That's a fundamentally different problem, and in captchaapi.eu I handle it with the PoW challenge itself.&lt;/p&gt;

&lt;p&gt;One more thing worth being explicit about: for HTTP routes the recommended path in Laravel is the &lt;a href="https://laravel.com/docs/12.x/routing#rate-limiting" rel="noopener noreferrer"&gt;&lt;code&gt;throttle&lt;/code&gt; middleware&lt;/a&gt;, not manual rate-limiting code. This post is specifically about the manual pattern, which is what you reach for when rate-limiting non-HTTP operations, custom logic inside a controller, or anything where the throttle middleware isn't a fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;A small fix, but a few things clicked for me that hadn't before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. "It works" is not the same as "it's secure."&lt;/strong&gt; I had the documented manual pattern running in production for a while and never saw a bug, because no attacker had shown up at captchaapi.eu yet. These races are quiet. Application logs say nothing, monitoring shows green, and you only find out the limit never actually held when someone with &lt;code&gt;wrk -c 100&lt;/code&gt; decides to take a look. Now any time I review code that rate-limits an expensive operation, I start with: &lt;em&gt;"What happens if 100 requests arrive in the same microsecond?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Return values from atomic operations are code you don't have to write.&lt;/strong&gt; Redis hands you a unique sequence number for free with every atomic increment. You can use it for rejection, sure, but also for other business logic you'd otherwise solve with more code and more locks. Calling &lt;code&gt;tooManyAttempts()&lt;/code&gt; and ignoring &lt;code&gt;increment()&lt;/code&gt;'s return value means throwing away information Redis already gave you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The documented manual pattern isn't always the safest one.&lt;/strong&gt; The Laravel docs have shown &lt;code&gt;tooManyAttempts() + increment()&lt;/code&gt; (or &lt;code&gt;hit()&lt;/code&gt; in older versions) under "Manually Incrementing Attempts" across every version from 8.x through 12.x. It isn't wrong. For most use cases (per-user limits, where users don't realistically make 100 concurrent requests) it's fine. But if you're building something where parallel abuse &lt;em&gt;is&lt;/em&gt; the threat model, the docs aren't showing you the safest option. I read documentation a bit differently now: &lt;em&gt;"Who's the assumed user here, and does their threat model match mine?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. X is where I pick up security gotchas.&lt;/strong&gt; This particular fix reached me through &lt;a href="https://x.com/_newtonjob/status/2039031311076139489" rel="noopener noreferrer"&gt;a 280-character post from @_newtonjob&lt;/a&gt;, not through the docs or a security audit. Following Laravel folks who share concrete pattern-level bugs from real apps has done more for me than most security blog posts. Keep people in your feed who write "I hit X, fixed it like this." That's exactly the kind of pattern matching you need for your own code. Thanks, &lt;a href="https://x.com/_newtonjob" rel="noopener noreferrer"&gt;@_newtonjob&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Race-prone — 100 concurrent requests will all get through&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tooManyAttempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Too many!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Race-safe — atomic increment + check the return value&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'Too many!'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line shorter, one problem gone. If you've got Laravel rate-limiting code using the &lt;code&gt;tooManyAttempts() + increment()&lt;/code&gt; (or &lt;code&gt;hit()&lt;/code&gt;) two-step pattern, go through it and rewrite to the single-call variant. Especially if you're protecting an operation where every call costs you something.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>redis</category>
    </item>
    <item>
      <title>CAPTCHA without cookies: a proof-of-work approach</title>
      <dc:creator>Vladislav Rajtmajer</dc:creator>
      <pubDate>Mon, 11 May 2026 05:39:55 +0000</pubDate>
      <link>https://dev.to/vladislav_rajtmajer_18389/captcha-without-cookies-a-proof-of-work-approach-pon</link>
      <guid>https://dev.to/vladislav_rajtmajer_18389/captcha-without-cookies-a-proof-of-work-approach-pon</guid>
      <description>&lt;p&gt;We've all been there. You're trying to sign in, you click "I'm not a robot", and instead of a simple checkbox you get a 3×3 grid of blurry photos. Click all squares with traffic lights. You miss one — was that a &lt;em&gt;real&lt;/em&gt; traffic light, or just a pole with a light on top of it? Wrong. New grid. Crosswalks this time. By the third round you've forgotten what you were trying to do in the first place.&lt;/p&gt;

&lt;p&gt;That's the visible part. Behind it, there's something less visible: a small army of cookies and trackers that decide whether you "look human" enough to be let through. The cookies do more than rate-limit your CAPTCHA — they feed a profiling graph that spans every site you've ever visited that uses the same provider.&lt;/p&gt;

&lt;p&gt;This post is the engineering write-up of how I built a CAPTCHA that doesn't do any of that, and the trade-offs that came with it. It's not a privacy rant; it's an honest engineering question: do CAPTCHAs actually &lt;em&gt;need&lt;/em&gt; cookies? Or did we end up with cookies because they were the easiest tool when the problem was first solved, and nobody went back to challenge that assumption after GDPR shifted the cost equation underneath?&lt;/p&gt;

&lt;p&gt;Cookies turn out not to be necessary. I built &lt;a href="https://captchaapi.eu" rel="noopener noreferrer"&gt;captchaapi.eu&lt;/a&gt; without them. Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually inside reCAPTCHA's data flow
&lt;/h2&gt;

&lt;p&gt;I'll use reCAPTCHA as the canonical example because it's what most EU developers default to. When a visitor hits a page with reCAPTCHA enabled, the following happens in the background:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;_GRECAPTCHA&lt;/code&gt; cookie is set on &lt;code&gt;google.com&lt;/code&gt; (cross-site cookie via iframe).&lt;/li&gt;
&lt;li&gt;If the visitor is logged into a Google account, additional &lt;code&gt;SID&lt;/code&gt;, &lt;code&gt;HSID&lt;/code&gt;, &lt;code&gt;SSID&lt;/code&gt;, &lt;code&gt;APISID&lt;/code&gt;, &lt;code&gt;SAPISID&lt;/code&gt; cookies are read from the Google account session.&lt;/li&gt;
&lt;li&gt;Browser metadata is harvested: user agent, screen resolution, plugins, font list, time zone, language settings.&lt;/li&gt;
&lt;li&gt;A risk score is computed based on the visitor's recent activity &lt;em&gt;across every Google property they've visited&lt;/em&gt; and every site that uses reCAPTCHA.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is what matters for GDPR. The risk model isn't local to your site — it's a cross-property graph spanning Google Search, Gmail, YouTube, every reCAPTCHA-protected site, and a few less obvious sources. Google calls it "advanced risk analysis"; from a GDPR perspective it's classic profiling under Article 4(4).&lt;/p&gt;

&lt;p&gt;Plus: data leaves the EU. Google's processors are global, anchored in the US. After the &lt;em&gt;Schrems II&lt;/em&gt; ruling invalidated Privacy Shield in 2020, EU data transfers to Google require Standard Contractual Clauses or DPF certification, and the transfer impact assessment has to acknowledge the surveillance risk that the CJEU explicitly flagged.&lt;/p&gt;

&lt;p&gt;For an EU SaaS asking visitors to "click all squares with traffic lights", that's compliance overhead. It's not technically impossible — Google publishes their DPF certification, you sign their DPA, you tick the boxes. But it's the kind of overhead that creates downstream friction: cookie banners, CMP integrations, DPO sign-offs, and the worry about what happens when the next Schrems judgment lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why traditional CAPTCHAs need cookies
&lt;/h2&gt;

&lt;p&gt;Cookies aren't in CAPTCHAs because someone decided to be evil. They were there because they solved real engineering problems when the category was first built:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Session continuity.&lt;/strong&gt; A CAPTCHA challenge has two phases: issue and verify. The server needs to know "this verification request is for that specific challenge". Without state, you have to put the challenge into the cookie itself, signed and timestamped.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rate limiting per visitor.&lt;/strong&gt; A bot can fake a User-Agent, but a freshly-minted cookie identifier is a useful (if weak) signal that this is a new session. Cookies give you a stable handle to throttle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Risk scoring across visits.&lt;/strong&gt; "This visitor has solved 17 CAPTCHAs in the last 60 seconds" is a useful signal. Without per-visitor identity, you can't accumulate it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-property correlation.&lt;/strong&gt; "This visitor is Trusted User™ on 4,000 reCAPTCHA-protected sites" is what makes invisible CAPTCHA possible. Without it, every site evaluates strangers.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these has an alternative when you're willing to redesign the protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof-of-work alternative
&lt;/h2&gt;

&lt;p&gt;The shape of a proof-of-work CAPTCHA is very simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Server issues a challenge: a random seed plus a difficulty target (a 32-bit integer).&lt;/li&gt;
&lt;li&gt;Client iterates a counter, computing &lt;code&gt;SHA-256(seed || counter)&lt;/code&gt; until the result is numerically below the target.&lt;/li&gt;
&lt;li&gt;Client submits the winning counter (the "nonce") to the server.&lt;/li&gt;
&lt;li&gt;Server re-hashes once to verify, accepts if valid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no cookie. The challenge state lives in server-side cache (Redis, 2-minute TTL) keyed by the challenge ID. The client only needs the seed and target, both of which arrive in the issue response. When the client submits the nonce, it submits the challenge ID alongside; the server looks it up, re-hashes, and accepts.&lt;/p&gt;

&lt;p&gt;What about rate limiting without cookies? &lt;strong&gt;Hash the IP.&lt;/strong&gt; Specifically: &lt;code&gt;SHA-256(IP || server-secret-salt)&lt;/code&gt;, held in cache only — up to 2 minutes for the 60-second per-IP rate limiter and up to 24 hours for a cross-sitekey abuse-reputation counter that detects distributed attacks. Never written to disk. This is what GDPR Article 4(5) calls &lt;em&gt;pseudonymisation&lt;/em&gt; — the original data can't be recovered without the salt, and the salt never leaves my server. The visitor's actual IP is never persisted.&lt;/p&gt;

&lt;p&gt;What about risk scoring without a cross-site profile? &lt;strong&gt;Adaptive difficulty per IP rate.&lt;/strong&gt; If a single hashed IP issues 1,000 challenges in 60 seconds, the difficulty for the next challenge from that IP scales up proportionally. This isn't ML-grade scoring — but it's enough to cost a botnet meaningful CPU time without correlating users across sites.&lt;/p&gt;

&lt;p&gt;The trade-off: I'm shifting the cost from "data on the visitor's device" to "CPU on the visitor's device". For tens of milliseconds of SHA-256 work — measured below — no cookie is set, no fingerprint is taken, no cross-site graph is built. For most EU SaaS forms — login, signup, contact, password reset, newsletter — that trade is straightforwardly favourable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation insights
&lt;/h2&gt;

&lt;p&gt;A few things turned out more interesting than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The IP hash needs a server-side secret.&lt;/strong&gt; A naïve &lt;code&gt;SHA-256(IP)&lt;/code&gt; is reversible — there are only ~4 billion IPv4 addresses, and a precomputed rainbow table fits on a USB stick. Adding a server-side secret salt makes the hash non-reversible to anyone without server access. In practice this means hashing happens server-side; the client never sees the salt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis matters more than I thought.&lt;/strong&gt; PostgreSQL would work, but a 2-minute TTL on millions of ephemeral keys is a workload Redis is built for and Postgres isn't. The DB stays out of the hot path entirely; it only sees account data, project keys, and billing records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Workers are non-negotiable.&lt;/strong&gt; PoW computation on the main thread freezes the UI for visible milliseconds, especially on lower-end devices. Pushing the work into a &lt;code&gt;Worker&lt;/code&gt; keeps the page responsive while the math happens in the background. The widget itself is ~19 KB minified, ~7 KB gzipped — small enough that the bundle-size argument against PoW just doesn't apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adaptive difficulty needs to feel invisible to humans.&lt;/strong&gt; Baseline target is set so a single "fresh" request from any IP completes well under 100 ms on average hardware. Difficulty escalates &lt;em&gt;only&lt;/em&gt; when the same hashed IP issues many requests in quick succession. A normal human visitor hitting one form per minute will never see anything but baseline. A botnet trying to brute-force a login page will hit a difficulty wall within seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side is unglamorous.&lt;/strong&gt; PHP/Laravel + Redis + PostgreSQL on a single Hetzner Cloud server in Nuremberg. No Kubernetes, no microservices. The whole thing runs on hardware that costs less per month than a London lunch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real measurements
&lt;/h3&gt;

&lt;p&gt;Numbers are easy to claim and hard to verify, so the widget includes a &lt;code&gt;data-captcha-debug&lt;/code&gt; flag that logs the timing breakdown to the browser console. Here's what came back from production captchaapi.eu (the base PoW curve is the same on every plan — these numbers apply uniformly to Free and Business visitors):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;PoW solve (median)&lt;/th&gt;
&lt;th&gt;Network RTT&lt;/th&gt;
&lt;th&gt;Total end-to-end&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mac mini M4 (desktop)&lt;/td&gt;
&lt;td&gt;~20 ms&lt;/td&gt;
&lt;td&gt;~210 ms&lt;/td&gt;
&lt;td&gt;~234 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone (Apple Silicon)&lt;/td&gt;
&lt;td&gt;~63 ms&lt;/td&gt;
&lt;td&gt;~190 ms&lt;/td&gt;
&lt;td&gt;~234 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median of 5 runs per device, lucky stochastic outliers (sub-1000 PoW iterations) excluded.&lt;/p&gt;

&lt;p&gt;Two things stand out. First, mobile and desktop produce &lt;strong&gt;statistically identical total times&lt;/strong&gt; — not because the iPhone is as fast as an M4 Mac at SHA-256 (it isn't, it's about 3× slower per iteration), but because the PoW work is dwarfed by the HTTPS round-trip to a Nuremberg-anchored EU API. The math is invisible relative to the network.&lt;/p&gt;

&lt;p&gt;Second, PoW solve under 100 ms even on a flagship phone — and the curve is tier-agnostic, so this number applies uniformly to every plan from Free to Business. The "PoW captchas are slow on mobile" assumption — which I held myself before measuring — turns out not to survive contact with current Apple Silicon. JavaScriptCore's JIT compiles the SHA-256 loop into something close to native ARM speed. The iPhone runs essentially the same engine architecture as the Mac.&lt;/p&gt;

&lt;p&gt;Anyone reading this can verify on their own hardware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;data-captcha&lt;/span&gt; &lt;span class="na"&gt;data-captcha-debug&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/login"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- your fields --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open DevTools console, reload, see four timing lines per challenge. No DevTools Performance traces required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest trade-offs
&lt;/h2&gt;

&lt;p&gt;PoW isn't strictly superior. Here's where it loses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sophisticated, well-funded bots.&lt;/strong&gt; A botnet with cheap CPU can burn through difficulty escalation. ML-based scoring (which is what reCAPTCHA invests in heavily) catches behavioural signals that PoW alone won't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile battery cost.&lt;/strong&gt; Tens of milliseconds of SHA-256 work on a phone is a measurable battery hit, even if it's tiny per request — in aggregate, an Apple Silicon phone solving PoW for every form on the web would notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threat-model coverage.&lt;/strong&gt; PoW protects against "automation by default" — typical scraping bots, opportunistic credential stuffing, low-effort spam. It doesn't protect against ad fraud, account farming for high-value targets, or sophisticated targeted attacks. For those you need behavioural ML, device fingerprinting, or human review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CAPTCHA-solver services.&lt;/strong&gt; Networks paying humans 50¢ per 1,000 solved CAPTCHAs work just as well against PoW (the human clicks "Verify" and waits 100 ms — the underlying mechanism doesn't matter to them).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most EU SaaS use cases — login forms, signup forms, contact forms, comments, newsletter signups, simple bot deterrence — these limitations don't matter. For exchanges, betting platforms, gaming auth, and other high-value targets, layer PoW with additional defences — or use a different category of tool entirely. Honest answer: if you're at that scale, you probably already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I open-source the widget
&lt;/h2&gt;

&lt;p&gt;The widget code that runs on every visitor's device is published at &lt;code&gt;/captcha.js&lt;/code&gt; — minified for size, but not obfuscated. The Proof-of-Work worker code is preserved verbatim with comments inside the bundle. Beautify the file and you can audit byte-for-byte what runs on visitors' devices.&lt;/p&gt;

&lt;p&gt;I do this because if I'm running code on someone else's device, they should be able to audit it. The customer who integrates my widget can verify what I'm doing on their visitors' machines. The visitor can verify what I'm doing on their machine. The whole model rests on "I'm not collecting data" — the easiest way to validate that claim is to make the code readable.&lt;/p&gt;

&lt;p&gt;It also keeps me honest. Five years from now, when I've forgotten what was important about the design, I'll be able to read my own code and remember. Future-me is one of the people the open bundle is for.&lt;/p&gt;

&lt;p&gt;I'd rather be reverse-engineered than trusted blindly. Both are fine. Trusted-after-audit is even better.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on what this is and isn't
&lt;/h2&gt;

&lt;p&gt;I should be straight about what I'm shipping, because the legal docs and the trust page already are.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://captchaapi.eu" rel="noopener noreferrer"&gt;captchaapi.eu&lt;/a&gt; is a one-person project. There's no enterprise SLA, no 24/7 on-call rotation, no operator-level ISO 27001 certificate (the underlying Hetzner infrastructure has those; my application layer doesn't, and I refuse to claim certifications I haven't earned). If your procurement team needs those things, this isn't the tool for you yet — and I'd rather tell you upfront than at contract-renewal time.&lt;/p&gt;

&lt;p&gt;I'm also not trying to disrupt the CAPTCHA market. I'm not optimising for a revenue curve. I built this because a German customer of mine needed a CAPTCHA on their forms but couldn't reasonably justify reCAPTCHA or Cloudflare Turnstile under their compliance posture, and I went looking for a low-cost EU-only alternative aimed at small developers and small businesses — and the gap was wider than I'd expected. FriendlyCaptcha and a few others exist, but their pricing optimises for enterprise tiers, not for a freelancer or a 5-person startup running a side project at €9 a month. The lower price tiers were missing — and the engineering wasn't actually that hard.&lt;/p&gt;

&lt;p&gt;I know that "honesty as a business strategy" is usually a polite way to say "won't scale". Maybe. I care more about shipping a thing I can be proud of than about the curve. If it works out, great; if it doesn't, the code is published, the design write-up is here, and someone else can build the next iteration without re-deriving the protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;GDPR made tracking expensive. &lt;em&gt;Schrems II&lt;/em&gt; made US-anchored providers risky. PoW computation on modern devices is cheap enough that the alternative is now just a different kind of "fast enough". If you've been keeping reCAPTCHA on a form because switching seemed complicated — the technical objection has mostly evaporated. PoW CAPTCHAs work, they don't need cookie banners, the widget bundle ships in ~7 KB gzipped.&lt;/p&gt;

&lt;p&gt;If &lt;a href="https://captchaapi.eu" rel="noopener noreferrer"&gt;captchaapi.eu&lt;/a&gt; fits your shape, the Free tier covers 5,000 challenges a month. If you'd rather build your own, the engineering recipe is in this post — go for it. Both are reasonable choices in 2026.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>captcha</category>
      <category>eu</category>
    </item>
  </channel>
</rss>
