<?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.us-east-2.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>reCAPTCHA-Alternative: DSGVO-konform und EU-gehostet</title>
      <dc:creator>Vladislav Rajtmajer</dc:creator>
      <pubDate>Wed, 17 Jun 2026 06:31:00 +0000</pubDate>
      <link>https://dev.to/vladislav_rajtmajer_18389/recaptcha-alternative-dsgvo-konform-und-eu-gehostet-19io</link>
      <guid>https://dev.to/vladislav_rajtmajer_18389/recaptcha-alternative-dsgvo-konform-und-eu-gehostet-19io</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Dieser Beitrag erschien zuerst auf &lt;a href="https://captchaapi.eu/de/blog/recaptcha-alternative-dsgvo?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=recaptcha-alternative-dsgvo"&gt;captchaapi.eu&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;reCAPTCHA ist das CAPTCHA, das die meisten Entwickler zuerst einbauen. Es ist kostenlos, in fünf Minuten integriert und fängt Bots zuverlässig ab. Der Haken zeigt sich erst, wenn jemand aus der Rechtsabteilung oder ein Datenschutzbeauftragter auf die Seite schaut: reCAPTCHA ist ein US-Dienst, und damit hängt an jedem geschützten Formular eine Kette von Pflichten, die seit 2026 vollständig bei Ihnen liegt.&lt;/p&gt;

&lt;p&gt;Dieser Beitrag erklärt, was reCAPTCHA datenschutzrechtlich auslöst, warum ein Bußgeldbescheid das nicht nur theoretisch macht, und wie eine EU-gehostete Alternative dieselbe Aufgabe ohne Cookies und ohne Banner löst.&lt;/p&gt;

&lt;h2&gt;
  
  
  Die kurze Antwort
&lt;/h2&gt;

&lt;p&gt;reCAPTCHA lässt sich DSGVO-konform betreiben, aber die Konformität entsteht nicht durch einen Schalter beim Anbieter, sondern durch das, was Sie drumherum aufsetzen: eine Einwilligung samt Cookie-Banner, eine Risikoabschätzung der Übermittlung in die USA und seit April 2026 die volle Verantwortlichkeit für die Verarbeitung. Wer diese Aufgaben nicht tragen will, ist mit einem CAPTCHA besser bedient, das von vornherein in der EU bleibt und keine Cookies setzt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Im direkten Vergleich:&lt;/strong&gt; die vollständige &lt;a href="https://captchaapi.eu/de/compare?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=recaptcha-alternative-dsgvo#vs-recaptcha"&gt;Gegenüberstellung mit reCAPTCHA, hCaptcha, Turnstile und Friendly Captcha&lt;/a&gt; zeigt jede Zeile mit den Quellen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;reCAPTCHA&lt;/th&gt;
&lt;th&gt;captchaapi.eu&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Betreiber&lt;/td&gt;
&lt;td&gt;Google LLC (USA)&lt;/td&gt;
&lt;td&gt;Einzelunternehmer (Tschechien, EU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;global, US-verankert&lt;/td&gt;
&lt;td&gt;nur EU (Hetzner, Nürnberg)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookies&lt;/td&gt;
&lt;td&gt;ja (&lt;code&gt;_GRECAPTCHA&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;keine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie-Banner nötig&lt;/td&gt;
&lt;td&gt;ja&lt;/td&gt;
&lt;td&gt;nein&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Übermittlung in die USA&lt;/td&gt;
&lt;td&gt;ja (Standarddatenschutzklauseln / DPF)&lt;/td&gt;
&lt;td&gt;keine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abwehr&lt;/td&gt;
&lt;td&gt;ML-Risk-Scoring&lt;/td&gt;
&lt;td&gt;deterministischer Proof of Work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kostenlos&lt;/td&gt;
&lt;td&gt;10.000/Monat pro Organisation&lt;/td&gt;
&lt;td&gt;5.000/Monat, kommerziell erlaubt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Einstiegstarif&lt;/td&gt;
&lt;td&gt;ab 8 $ (10.001-100.000)&lt;/td&gt;
&lt;td&gt;9 €/Monat (20.000 Anfragen)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Was reCAPTCHA datenschutzrechtlich auslöst
&lt;/h2&gt;

&lt;p&gt;Drei Dinge passieren, sobald ein Besucher auf eine Seite mit reCAPTCHA trifft, und alle drei sind aus DSGVO-Sicht relevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Erstens: Daten verlassen die EU.&lt;/strong&gt; Googles Infrastruktur ist global und in den USA verankert. Seit der EuGH im Verfahren &lt;em&gt;Schrems II&lt;/em&gt; (C-311/18) das Privacy Shield gekippt hat, stützt sich jede Übermittlung in die USA auf Standarddatenschutzklauseln plus eine Transferprüfung, die das vom Gericht benannte Überwachungsrisiko berücksichtigen muss. Das EU-US Data Privacy Framework von 2023 hat zwar wieder einen Angemessenheitsweg für zertifizierte Importeure geschaffen, steht aber bereits unter rechtlicher Anfechtung. So oder so verlassen Sie sich auf einen US-Transfermechanismus, statt die Daten gar nicht erst aus der EU zu lassen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zweitens: reCAPTCHA setzt Cookies und analysiert seitenübergreifend.&lt;/strong&gt; Das Widget setzt ein &lt;code&gt;_GRECAPTCHA&lt;/code&gt;-Cookie und berechnet einen Risikowert aus dem Verhalten des Besuchers über alle reCAPTCHA-geschützten Seiten hinweg - und, falls er angemeldet ist, zusätzlich aus seinem Google-Kontostatus. Google nennt das "advanced risk analysis"; aus DSGVO-Sicht ist eine solche seitenübergreifende Analyse meines Erachtens als Profiling nach Art. 4 Nr. 4 einzuordnen. (Google gibt an, diese Daten nicht für personalisierte Werbung zu nutzen.) Weil hier nicht nur unbedingt erforderliche Informationen im Spiel sind, brauchen Sie nach § 25 TDDDG und Art. 6 Abs. 1 DSGVO eine Einwilligung, und damit ein Cookie-Banner vor dem Formular.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drittens: Sie sind seit April 2026 allein verantwortlich.&lt;/strong&gt; Zum 2. April 2026 tritt Google für reCAPTCHA nur noch als Auftragsverarbeiter auf. Sie waren immer Verantwortlicher, weil Sie entscheiden, warum und wie das CAPTCHA läuft, aber Google hat die Daten zuvor auch für eigene Zwecke verarbeitet. Jetzt liegt die Verantwortung vollständig bei Ihnen, und Googles eigener Migrationshinweis fordert Sie auf, Verweise auf Googles Datenschutzerklärung und Nutzungsbedingungen von Ihrer Seite zu entfernen. Das heißt konkret: AVV prüfen, Transferprüfung dokumentieren, Einwilligung sauber einholen, Informationspflichten nach Art. 13 erfüllen. Alles bei Ihnen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Der Fall Cityscoot: kein theoretisches Risiko
&lt;/h2&gt;

&lt;p&gt;Dass das kein Papiertiger ist, zeigt ein konkreter Bescheid. Die französische Aufsichtsbehörde CNIL verhängte im März 2023 ein Bußgeld von 125.000 € gegen den Anbieter Cityscoot. Einer der Gründe war der Einsatz von reCAPTCHA ohne vorherige Einwilligung, geahndet nach Art. 82 des französischen Datenschutzgesetzes (Entscheidung SAN-2023-003). Der Punkt ist nicht "reCAPTCHA ist verboten", sondern: ein CAPTCHA, das Cookies setzt und ohne Einwilligung läuft, ist ein bekanntes und bereits geahndetes Risiko.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wo reCAPTCHA trotzdem gewinnt
&lt;/h2&gt;

&lt;p&gt;Damit das fair bleibt: reCAPTCHA ist technisch stark und bei bestimmtem Traffic schwer zu schlagen. Das ML-Scoring läuft unsichtbar im Hintergrund, und wer normal wirkt, kommt ganz ohne sichtbare Aufgabe durch. Bei sehr hohem Consumer-Traffic, bei dem jede zusätzliche Sekunde Conversion kostet, ist dieser reibungslose Durchlauf ein echter Vorteil. Wenn Sie auf möglichst geringe Reibung optimieren und mit einer Verarbeitung außerhalb der EU einverstanden sind, kann reCAPTCHA die richtige Wahl bleiben.&lt;/p&gt;

&lt;p&gt;Der Tausch ist nur eben nicht kostenlos: Sie zahlen ihn in Cookie-Banner, Drittlandtransfer und Verantwortlichkeit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Die Alternative: nur EU, ab der ersten Anfrage
&lt;/h2&gt;

&lt;p&gt;captchaapi.eu setzt an genau diesem Tausch an. Der Schutz ist deterministischer Proof of Work: Der Browser des Besuchers löst ein kleines Rechenrätsel, bevor das Formular abgeschickt wird. Kein Scoring, das Verhalten über Seiten hinweg auswertet, kein Profil.&lt;/p&gt;

&lt;p&gt;Daraus folgt der datenschutzrechtliche Unterschied:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hosting nur in der EU.&lt;/strong&gt; Verarbeitung ausschließlich bei Hetzner in Nürnberg. Keine Übermittlung in ein Drittland, also auch keine Transferprüfung und keine Standarddatenschutzklauseln nötig.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keine Cookies, kein Banner.&lt;/strong&gt; Das Widget setzt keine Cookies und macht kein Fingerprinting. Die einzige betroffene Information ist die IP-Adresse des Besuchers, und die wird nur als Einweg-Hash (SHA-256 mit serverseitigem Salt) im flüchtigen Cache gehalten, nie in einer Datenbank gespeichert. Damit besteht für die CAPTCHA-Ebene kein Einwilligungserfordernis nach § 25 TDDDG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AVV liegt vor.&lt;/strong&gt; Einen vorab unterzeichneten AVV nach Art. 28 gibt es als öffentliche Seite zum Herunterladen, ohne Beschaffungsschleife. Federführende Aufsichtsbehörde ist die tschechische ÚOOÚ.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Der Abwehrmechanismus ist bewusst anders gelagert: Proof of Work hält seine Kosten unter koordinierten Angriffen vorhersehbar, weil sie nicht davon abhängen, dass die Erkennungsgenauigkeit eines Modells stabil bleibt. Und die Entscheidungslogik ist nachvollziehbar statt undurchsichtig.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tarife
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tarif&lt;/th&gt;
&lt;th&gt;reCAPTCHA&lt;/th&gt;
&lt;th&gt;captchaapi.eu&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kostenlos&lt;/td&gt;
&lt;td&gt;10.000/Monat pro Organisation&lt;/td&gt;
&lt;td&gt;5.000/Monat, kommerziell erlaubt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Einstieg&lt;/td&gt;
&lt;td&gt;ab 8 $ (10.001-100.000)&lt;/td&gt;
&lt;td&gt;9 €/Monat - 20.000 Anfragen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wachstum&lt;/td&gt;
&lt;td&gt;1 $/1.000 darüber&lt;/td&gt;
&lt;td&gt;29 €/Monat - 100.000 Anfragen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Der kostenlose Tarif bei captchaapi.eu erlaubt ausdrücklich die kommerzielle Nutzung, und über das Kontingent hinaus werden zahlende Tarife nicht hart blockiert, sondern weiter mit derselben Schwierigkeitsstufe bedient. Der Besucher hat den Tarif nicht ausgesucht, also trägt er auch nicht die Folgen, wenn das Kontingent erschöpft ist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Umstieg in der Praxis
&lt;/h2&gt;

&lt;p&gt;Der Wechsel ist kein Projekt. Sie tauschen das reCAPTCHA-Skript gegen &lt;code&gt;captcha.js&lt;/code&gt;, ersetzen die Server-Prüfung durch einen Aufruf gegen den &lt;code&gt;/verify&lt;/code&gt;-Endpoint und entfernen anschließend den Cookie-Banner-Eintrag für das CAPTCHA. Letzteres ist oft die sichtbarste Verbesserung für Ihre Besucher.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Auf WordPress?&lt;/strong&gt; Den Umstieg gibt es als fertiges Plugin aus dem offiziellen Verzeichnis - es schützt Anmeldung, Registrierung, Kommentare und Contact Form 7 ohne eine Zeile Code und ohne Cookie-Banner. Siehe &lt;a href="https://captchaapi.eu/de/blog/dsgvo-captcha-wordpress-plugin?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=recaptcha-alternative-dsgvo"&gt;DSGVO-Captcha für WordPress&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Worum es eigentlich geht
&lt;/h2&gt;

&lt;p&gt;reCAPTCHA ist nicht verschwunden und nicht verboten. Es ist nach dem Wechsel zu Google Cloud nur ehrlicher geworden, was die Rollenverteilung angeht: Die Verantwortung sitzt jetzt klar bei Ihnen. Für einen Anbieter mit EU-Nutzern war die datenschutzrechtliche Lage aber schon vorher der unangenehme Teil - Drittlandtransfer, Einwilligung, Cookie-Banner auf jeder geschützten Seite.&lt;/p&gt;

&lt;p&gt;Wenn Sie diese Aufgaben übernehmen wollen, ist reCAPTCHA weiterhin ein solides Werkzeug. Wenn Sie sie lieber gar nicht erst entstehen lassen, ist genau das der Grund, warum dieses Produkt existiert. &lt;a href="https://captchaapi.eu/register?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=recaptcha-alternative-dsgvo"&gt;Kostenlos starten&lt;/a&gt;, keine Kreditkarte nötig.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;reCAPTCHA-Konditionen: Stand Juni 2026, laut Googles eigener Preisübersicht. Preise und Bedingungen ändern sich - prüfen Sie vor einer Entscheidung die aktuellen Angaben auf Googles Seite.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dsgvo</category>
      <category>gdpr</category>
      <category>captcha</category>
      <category>privacy</category>
    </item>
    <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;p&gt;&lt;strong&gt;Update (June 2026):&lt;/strong&gt; After digging into this, I sent a warning to the Laravel docs, right next to the example in question. It &lt;a href="https://github.com/laravel/docs/pull/11234" rel="noopener noreferrer"&gt;got merged&lt;/a&gt; — the "Manually Incrementing Attempts" section now flags the race and shows the atomic fix. The rest of this post is unchanged.&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 showed &lt;code&gt;tooManyAttempts() + increment()&lt;/code&gt; (or &lt;code&gt;hit()&lt;/code&gt; in older versions) under "Manually Incrementing Attempts" for years, from 8.x onward, with no caveat. 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 weren't showing you the safest option. When I ran into it I sent a warning to the docs along with the atomic fix, so it's been on the page since 13.x. Either way, 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;blockquote&gt;
&lt;p&gt;This post first appeared on &lt;a href="https://captchaapi.eu/blog/captcha-without-cookies-proof-of-work?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=captcha-without-cookies-proof-of-work"&gt;captchaapi.eu&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&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 cross-site risk model that recognizes a visitor across the sites they touch that use 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?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=captcha-without-cookies-proof-of-work"&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 from the visitor's activity &lt;em&gt;across the reCAPTCHA-protected sites they've touched&lt;/em&gt;, combined with their Google account state when they're signed in.&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 spans the reCAPTCHA-protected sites a visitor touches, plus their Google account state when they're signed in. Google calls it "advanced risk analysis"; from a GDPR perspective it's classic profiling under Article 4(4). (Google states this data is not used for personalised advertising.)&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. I covered the 2026 reCAPTCHA changes and that compliance overhead in more depth in &lt;a href="https://captchaapi.eu/blog/recaptcha-alternative-gdpr?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=captcha-without-cookies-proof-of-work"&gt;a GDPR-compliant reCAPTCHA alternative&lt;/a&gt;.&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?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=captcha-without-cookies-proof-of-work"&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?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=captcha-without-cookies-proof-of-work"&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;

&lt;p&gt;Corrections, technical objections, and missed considerations are welcome at &lt;a href="mailto:security@captchaapi.eu"&gt;security@captchaapi.eu&lt;/a&gt; — I read every email myself.&lt;/p&gt;

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