Oliver White
8 May 2026 · 7 min read
A fixed window rate limiter has an exploitable boundary: vote at 11:59pm, vote again at 12:00am — two votes in two minutes, both within their respective windows. A sliding window tracks the rolling 24 hours and has no such boundary. For AI Art Arena, where one vote per person per contest is the core integrity guarantee, only one of these is acceptable.
A fixed window rate limiter works like this: you have a bucket that allows N requests. The bucket resets at a fixed interval — say, every 24 hours at midnight. The problem is the reset boundary.
Boundary attack: with a fixed 24-hour window resetting at midnight, a user can vote at 11:59pm and again at 12:00am — two votes in two minutes, both technically within their respective windows. Scale this up and you have a double-voting exploit.
| Algorithm | How it works | Boundary exploit? | Memory use | Accuracy |
|---|---|---|---|---|
| Fixed window | Counter resets at interval boundary | Yes — 2x requests at boundary | Low (1 key) | Low at boundaries |
| Sliding window (log) | Stores timestamp of every request | No | High (N entries per user) | Perfect |
| Sliding window (approx) | Weighted blend of current + previous window | Minimal | Low (2 keys) | ~99% accurate |
| Token bucket | Tokens refill at constant rate | No | Low | High |
Upstash Ratelimit uses the approximate sliding window algorithm — two counters, weighted by how far into the current window you are. It's not perfectly accurate at the exact window boundary, but the error margin is under 1% and it uses constant memory regardless of request volume.
The key design is as important as the algorithm. Keying by IP alone is too weak. Keying by user ID alone misses anonymous visitors. The solution is to use the strongest available identifier, with a fallback:
The key is scoped per-contest, not per-day. An IP or email hash gets one vote per contest total — not one per 24 hours globally. This means the Redis key for vote:abc123:contest-uuid expires only after the contest ends, not at midnight.
Both the rate limiter and the database use hashed email as a duplicate-vote signal. The hash uses VOTE_HASH_SALT as a secret input. This creates a critical constraint: if you change the salt after launch, all existing hashes in Redis and in the votes table become orphaned. The system loses its ability to detect that alice@example.com already voted — her new hash doesn't match her old one.
| If you change VOTE_HASH_SALT after launch... | Effect |
|---|---|
| Existing Redis keys | All rate limit history orphaned — every user can vote again immediately |
| votes.email_hash column | Existing hashes unmatchable — email-based duplicate detection breaks |
| Auth duplicate check | Users who voted before the change can vote again |
| Recovery | Must wipe all votes and Redis keys and restart the contest |
Treat VOTE_HASH_SALT like a database encryption key: generate it once, store it securely, never rotate it without a migration plan. The .env.example file marks it explicitly: 'Generate once with openssl rand -base64 32. Never change after first vote is cast.'
Vote rate limiting is one of five limiters running in the application. Each is tuned independently to its threat model:
| Limiter | Window | Limit | Key | Protects against |
|---|---|---|---|---|
| voteRateLimit | 24 hours | 1 | email/IP hash + contest_id | Duplicate voting |
| adminRateLimit | 1 minute | 100 | admin IP hash | Admin API abuse |
| adminUploadRateLimit | 1 hour | 10 | admin IP hash | Storage cost abuse |
| authRateLimit | 15 minutes | 5 | IP hash | Brute-force login |
| resetRateLimit | 1 hour | 3 | IP hash | Password reset spam |
All five use Upstash's serverless Redis — no persistent connection management, compatible with Vercel's edge and serverless functions, and analytics built in so you can see hit/miss ratios in the Upstash console without additional instrumentation.
Built with this methodology
A fixed window rate limiter resets at midnight. A sliding window tracks the rolling 24 hours. The difference sounds academic until someone figures out they can vote 18 times by straddling the reset boundary.
From the build log