Oliver White
18 May 2026 · 7 min read
Rate limiting a voting system requires a stable identifier for each visitor. The obvious choice is the IP address. The problem: storing IP addresses is a GDPR liability. They are personal data in most EU interpretations. You need them for rate limiting but you should not keep them in plaintext — not in Redis, not in the database, not in logs.
This is part of the Directed Output build log — every architectural decision behind AI Art Arena documented as it happened. The methodology lives at /process.
The solution is SHA-256 with a secret salt, truncated to 32 hex characters. The hash is deterministic — the same IP always produces the same hash — so you can use it as a stable rate-limit key. But it is not reversible. Given the hash, you cannot recover the original IP. The salt makes it resistant to rainbow table attacks.
| Approach | Reversible? | GDPR risk | Rate limiting works? |
|---|---|---|---|
| Store raw IP | Yes — plaintext | High — PII in database and Redis | Yes |
| Hash without salt | Partially — rainbow tables exist | Medium | Yes |
| Hash with secret salt | No — computationally infeasible | Minimal | Yes |
AI Art Arena uses two hash salts. IP_HASH_SALT is used for IP addresses throughout the application. VOTE_HASH_SALT is used exclusively for hashing email addresses into the votes table as a third duplicate-vote detection layer. They are separate environment variables deliberately: if one is compromised, the other is not affected. Using a single shared salt would mean a single leaked secret breaks both protections simultaneously.
Never use the same salt for IP hashes and email hashes. A compromised salt allows constructing a lookup table for that salt's hash space. Separate salts mean a single breach is contained.
This is the constraint that is easy to miss until it is too late. The salt is a secret input to a one-way hash. Every existing hash in the system was produced using the current salt. If you change the salt, all existing hashes become orphaned — the new salt produces different hashes for the same inputs, so the system cannot match a new vote attempt against existing vote records.
| If you change IP_HASH_SALT after launch... | Effect |
|---|---|
| Redis rate limit keys | All orphaned — every visitor can vote again immediately |
| votes.ip_hash column | Existing hashes unmatchable — IP duplicate detection broken |
| Recovery path | Must wipe all votes and Redis keys — full integrity reset |
The env var file has a comment that reads: 'Generate once with openssl rand -base64 32. Never change after the first vote is cast.' That comment is not convention — it is a hard constraint. Rotating the salt is equivalent to deleting your vote history.
Upstash Ratelimit offers both fixed window and sliding window algorithms. Fixed window has an exploitable boundary: vote at 11:59pm, vote again at 12:00am — two votes in two minutes, both within their respective windows. Sliding window tracks the rolling 24 hours and has no such boundary. For a system where one vote per contest is the core integrity guarantee, fixed window is not acceptable. All five rate limiters in this project use sliding window.
Rate limit keys are scoped per-contest, not per-day. The key format is ipHash:contestId — so the window resets per contest, not at midnight. An anonymous voter gets exactly one vote per contest regardless of when the contest runs.
Built with this methodology
Storing raw IP addresses is a GDPR liability. Hashing them one-way with a secret salt gives you everything you need for rate limiting with none of the exposure. Here is the exact implementation — and why the salt can never change after launch.
From the build log