<?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: Cheno</title>
    <description>The latest articles on DEV Community by Cheno (@chen0).</description>
    <link>https://dev.to/chen0</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%2F4086043%2Fb84125c6-a8bb-4720-9aaa-3297fe945e59.jpg</url>
      <title>DEV Community: Cheno</title>
      <link>https://dev.to/chen0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chen0"/>
    <language>en</language>
    <item>
      <title>I Built a Leak Detector for API Keys. My Synthetic Tests Caught 8 Out of 20</title>
      <dc:creator>Cheno</dc:creator>
      <pubDate>Mon, 24 Aug 2026 11:31:41 +0000</pubDate>
      <link>https://dev.to/chen0/i-built-a-leak-detector-for-api-keys-my-synthetic-tests-caught-8-out-of-20-h7g</link>
      <guid>https://dev.to/chen0/i-built-a-leak-detector-for-api-keys-my-synthetic-tests-caught-8-out-of-20-h7g</guid>
      <description>&lt;p&gt;The honest story of building Cerberus and why I need real traffic to finish it.&lt;/p&gt;

&lt;p&gt;￼&lt;br&gt;
A few days ago I wrote here about a test that failed. It was a synthetic data generator that fell over on real-world noise. I got more engagement on that post than anything I'd written before, which taught me something: developers don't trust perfection. They trust someone who shows the mess.&lt;br&gt;
So here's the mess I'm in now.&lt;br&gt;
The Problem&lt;br&gt;
I sell API access for a living. A few months ago a customer's key leaked—committed to a public repo, scraped by a bot, used from a hundred places. We found out when the bill landed. The gap between the leak and the discovery is where all the damage lives.&lt;br&gt;
What I Built&lt;br&gt;
Cerberus watches usage metadata per API key and looks for one signature: a key being used from many places at once, each doing very little work.&lt;br&gt;
A customer scaling legitimately runs on more machines and does proportionally more work, so requests-per-machine stays constant. A leaked credential inverts that ratio. More origins, less work per origin.&lt;br&gt;
The detection rule uses three signals:&lt;br&gt;
 • Origin count per key&lt;br&gt;
 • Work per origin&lt;br&gt;
 • Network spread (unrelated networks vs. one cloud provider)&lt;br&gt;
When all three hold for three consecutive hours, it sends one Slack message. That's it. No dashboard. No daily report. No noise.&lt;br&gt;
Where It Falls Apart&lt;br&gt;
I ran 20 synthetic leak scenarios through it. It caught eight.&lt;br&gt;
The misses are all on the most common key shape: low baseline traffic, suddenly distributed. The thresholds are guesses. Every number in the rule is a guess, and the code comments literally say so.&lt;br&gt;
I need real traffic history from actual API platforms to calibrate it. Synthetic data doesn't have the weird edge cases that real customers create.&lt;br&gt;
The Privacy Architecture&lt;br&gt;
Before anyone asks: keys and IPs never reach my server.&lt;br&gt;
The SDK computes an HMAC-SHA256 of each value under a secret salt the customer holds. I never receive that salt, so I cannot reverse a fingerprint—not for the customer, not for an attacker who breaches me, not for anyone who compels me legally.&lt;br&gt;
Twelve metadata fields only. Timestamp, hashed key, route template, token counts, latency, status, three hashed network fingerprints, address family, optional cost. The ingest endpoint rejects anything else by name. There is no field for a prompt, a response, a key, an address, or a user identifier. They can't be sent even by accident.&lt;br&gt;
What I'm Looking For&lt;br&gt;
Three API companies willing to install it free forever in exchange for sharing two weeks of traffic history so I can tune the detection.&lt;br&gt;
First installs are done by hand on a call, deliberately, so there's a person to ask when something looks wrong.&lt;br&gt;
If you sell API access and have ever worried about a leaked key, hit reply &lt;/p&gt;

</description>
      <category>api</category>
      <category>security</category>
    </item>
    <item>
      <title>We tested our rate limiter under concurrency. 128 threads made 128 attempts against a limit of 20.</title>
      <dc:creator>Cheno</dc:creator>
      <pubDate>Thu, 20 Aug 2026 06:22:19 +0000</pubDate>
      <link>https://dev.to/chen0/we-tested-our-rate-limiter-under-concurrency-128-threads-made-128-attempts-against-a-limit-of-20-2a8n</link>
      <guid>https://dev.to/chen0/we-tested-our-rate-limiter-under-concurrency-128-threads-made-128-attempts-against-a-limit-of-20-2a8n</guid>
      <description>&lt;p&gt;I’m building Cerberus, an API abuse detection service. Before it ships, I load-test the pieces. This is the story of one rate limiter bug that passed 300 tests, then failed immediately under threads.&lt;/p&gt;

&lt;p&gt;The documented limit for authenticated customers was 600 requests per minute.&lt;/p&gt;

&lt;p&gt;The real cap was 20.&lt;/p&gt;

&lt;p&gt;The bug&lt;br&gt;
Every request hit the unauthenticated bucket first. Then the system resolved the API token. If the token was valid, the request should not have counted against the unauthenticated bucket. But the charge had already happened.&lt;/p&gt;

&lt;p&gt;The order was wrong.&lt;/p&gt;

&lt;p&gt;Two SDK processes behind one NAT would exhaust the unauthenticated budget quickly. One process alone, running sequential requests, might never notice.&lt;/p&gt;

&lt;p&gt;The bug was invisible to 300 tests because every test called the endpoint one request at a time. The failure needed 21 requests inside one minute. Sequential tests never produced that.&lt;/p&gt;

&lt;p&gt;The fix introduced a race.&lt;br&gt;
The first fix looked simple: peek the budget, resolve the token, charge on failure.&lt;/p&gt;

&lt;p&gt;Correct sequentially.&lt;/p&gt;

&lt;p&gt;Under threads, the overshoot equaled the thread count exactly. 128 threads got 128 attempts against a limit of 20.&lt;/p&gt;

&lt;p&gt;The race window was between the budget check and the charge.&lt;/p&gt;

&lt;p&gt;Roughly:&lt;/p&gt;

&lt;p&gt;if bucket.available &amp;lt; 1:&lt;br&gt;
    reject&lt;/p&gt;

&lt;p&gt;token = resolve(request)&lt;/p&gt;

&lt;p&gt;if not tokenvalid:&lt;br&gt;
    bucket.charge(1)&lt;/p&gt;

&lt;p&gt;One thread can read bucket. available, pass the check, and pause. Another thread does the same. By the time any thread charges the bucket, too many requests are already through.&lt;/p&gt;

&lt;p&gt;That is not a rate limiter. That is a suggestion.&lt;/p&gt;

&lt;p&gt;Why it never showed over HTTP&lt;br&gt;
When we added concurrency, throughput went from 1 to 64 concurrent clients. Throughput only moved 1.16×.&lt;/p&gt;

&lt;p&gt;Latency went from 101ms to 3,822ms.&lt;/p&gt;

&lt;p&gt;That is a queue, not parallelism.&lt;/p&gt;

&lt;p&gt;An async handler doing blocking database calls serializes everything. The serialization hid the race. If only one request is ever inside the critical section at a time, the race window never opens.&lt;/p&gt;

&lt;p&gt;We only saw the overshoot when we tested the limiter directly under threads, without the HTTP layer hiding it.&lt;/p&gt;

&lt;p&gt;The test that had the bug was written to catch it&lt;br&gt;
There was a test designed to catch this class of bug.&lt;/p&gt;

&lt;p&gt;It excluded functions that open a database connection. The reasoning was that those functions are the openers, not the victims.&lt;/p&gt;

&lt;p&gt;The function that both opened a database connection and looped calling other functions was excluded.&lt;/p&gt;

&lt;p&gt;So the test passed.&lt;/p&gt;

&lt;p&gt;What changed&lt;br&gt;
Every rate limiter test now runs with threads, not sequential calls.&lt;/p&gt;

&lt;p&gt;We measure throughput and latency shape, not just pass/fail.&lt;/p&gt;

&lt;p&gt;We include functions that open resources in concurrency tests.&lt;/p&gt;

&lt;p&gt;We test the limiter directly and over HTTP.&lt;/p&gt;

&lt;p&gt;The real lesson&lt;br&gt;
Sequential tests will lie to you about anything that only fails under contention.&lt;/p&gt;

&lt;p&gt;If a rate limiter passes 300 tests, but no test sends 21 requests in a minute or runs threads, it has not been tested.&lt;/p&gt;

&lt;p&gt;The bug was not exotic. The order of two operations was wrong, and the test suite was structured to hide it. That is the normal failure mode for concurrency bugs.&lt;/p&gt;

&lt;p&gt;The fix is not to add more tests. It is to make the tests actually contend.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>api</category>
      <category>devops</category>
      <category>security</category>
    </item>
  </channel>
</rss>
