DEV Community

Virendra Vyas
Virendra Vyas

Posted on

I Rate-Limited My Own API. Then I Found a Way to Bypass It Myself.

Rate limiting feels like a solved problem. You pick a partition key, set a window, wire up the middleware, and move on. I did exactly that on GovBridge - a gateway sitting in front of several UK government APIs, and for a while I genuinely thought it was done.

Then I went looking for edge cases in my own system, the way you're supposed to before you trust something with production traffic. And I found one.

The bug

My rate limiter was partitioning requests by the raw x-api-key header value - a completely reasonable-sounding default. Different key, different bucket, different limit. Simple.

Except: nothing was actually checking whether that key was valid before the rate limiter ran. Authentication happened later in the pipeline. Which meant the rate limiter was perfectly happy to hand out a fresh limit bucket to any string you put in that header - real key or not.

If you wanted to bypass the limit entirely, you didn't need to break anything clever. You just needed to change the header value on every request. Each "new" fake key got its own untouched allowance. The limiter was doing exactly what I told it to do - it just wasn't protecting what I actually wanted protected.

This is the kind of bug that doesn't show up in normal testing, because normal testing uses real keys. It only shows up when you deliberately try to misuse your own system the way someone with bad intentions eventually would.

Why this is an easy trap to fall into

The instinct to partition by "whatever's in the header" feels natural, because it's the first thing available and it looks like identity. But a header value isn't identity — it's just a string someone sent you. Identity is what your authentication layer confirms after checking that string against something real.

Rate limiting on the wrong side of that line protects a piece of text, not a user.

The fix

Two changes, and they had to happen together:

  1. Authentication moved ahead of rate limiting in the middle-ware pipeline - so by the time the limiter runs, the request has already been through validation.

  2. The rate limiter's partition key changed from the raw header to the authenticated claim - the identity that validation actually confirmed, not the string that arrived on the wire.

Now an invalid key doesn't get a fresh bucket to exploit, it gets rejected by authentication before rate limiting ever sees it. And a real, valid key can't be rotated to dodge its own limit, because the limiter is now counting the thing being limited, not an arbitrary label attached to the request.

Why I'm writing this up

Not because the fix is clever, it's genuinely simple, in hindsight almost embarrassingly so. I'm writing it up because "partition by header, not by verified identity" is an easy default to reach for, and I don't think I'm the only person who's reached for it without noticing the gap.

If you've built rate limiting or throttling into anything - an API, a login flow, a signup form — it's worth a specific check: is the thing you're limiting actually confirmed to exist, or are you just limiting a string that showed up?

Cheaper to find that gap yourself than to have someone else find it for you.

Top comments (0)