Rate limiting in one table, no Redis

// written in 2023: tools, versions and prices may have changed since.

Part of my Krabber series, a Twitter clone in Go. The full source is on GitHub.

Intro

Rate limiting is one of those features you don’t think about until a bot is hammering your login page. Krabber does it in three layers, and the middle layer, the app’s own limits, lives in the same DynamoDB table as everything else, with no Redis in sight. In this post I’ll walk through all three, because they each stop a different kind of abuse.

I. The edge layer: WAF

The first limits a request meets are at the edge, in the WAF rules in front of CloudFront, before anything reaches my box. There’s a tight limit on the auth paths (/krab/login, /krab/signup and friends) of 20 requests per IP per five minutes, and a looser global limit of 500 per IP per five minutes across everything. A page is about five requests once you count its assets, so 500 is generous for a human and brutal for a scraper. This is the cheap, blunt layer: it soaks up floods at the edge so they never cost my origin a thing. I told the story of it eating a bot attack in the scrapersphere post.

II. The app layer: counters in the table

The edge works on raw IPs. But some limits need to be about an identity, like “this email address has failed to log in five times,” and that’s the app’s job. Krabber does it with fixed-window counters stored as items in the main table.

The key encodes the action and who it’s for, and the sort key is the time window:

func rateLimitPK(action, key string) string { return "RL#" + action + "#" + key }
func rateLimitSK(start time.Time) string     { return fmt.Sprintf("RL#%d", start.Unix()) }

So “login failures for alice@example.com in this 15-minute window” is one counter item. Each failure bumps it; past the limit, the request gets a generic “try again later.” The specific limits:

  • Login: 5 failures per email per 15 minutes, and 20 attempts per IP per 15 minutes.
  • Signup: 3 per IP per hour.

The per-email limit stops someone grinding one account, the per-IP limit stops a spray across many. And there’s a bonus: the login limit caps how much CPU I’ll spend on bcrypt, which is deliberately slow, so an attacker can’t make my box melt just by submitting passwords.

The nicest part is cleanup. Every counter carries an expires_at, and the table has TTL on, so DynamoDB deletes old windows for me. I never run a sweep. (The same counters leave a free audit trail, which is how I proved those scraper bots never even touched signup, there were no RL#signup-ip# items for that window.)

III. The per-krab layer: write limits in memory

The third layer bounds what a single logged-in krab, or a stolen session, can cost me. Per hour, a krab gets 100 molts, 1,000 likes, 300 follows, and 300 bookmarks. Past those, the action returns a 429 and the page politely says to slow down.

These are way above what a real person does in an hour, so nobody normal ever sees them. They exist because a molt isn’t free: it’s written once more into every follower’s feed, so a runaway account could generate a lot of writes fast. These counters are kept in memory on the instance, which means a restart resets them, and that’s fine. They’re a ceiling on abuse, not an accounting system, and keeping them in memory costs nothing.

Conclusion

Three layers, each cheap, each stopping something the others can’t: the WAF soaks up IP floods at the edge, the DynamoDB counters throttle by identity and clean themselves up with TTL, and the in-memory write limits cap what one account can spend. None of it needed a Redis or a rate-limit service, because the table I already run can hold a counter just as well as it holds a molt. If you’re adding rate limits to a small app, you probably don’t need a new dependency for it either. Thanks for reading, and may your login page stay quiet.

cd ../blog