FRD-006
A cookieless visitor key you can count on
One rotating hash that identifies a visitor for limits and counting, without a cookie to clear and without storing anything that identifies a person.
Per-visitor limits need a key, and a cookie is the worst possible one: clearing it is a menu item. Anyone farming your site will clear it between page loads.
A hash of the address, the user agent and a salt that rotates daily gives you a key that survives a cookie wipe, cannot be reversed into an address, and expires on its own. It is not an identity — two people behind one router share it — which is exactly the right trade for a fraud limit.
The daily rotation is deliberate. It stops the key from becoming a long-term identifier, which is both a privacy improvement and one less thing to explain in your policy page.
function visitor_key(?string $ip = null, ?string $ua = null): string
{
$ip = $ip ?? client_ip();
$ua = $ua ?? (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
// Salt rotates daily: the key cannot be correlated across days.
$salt = date('Y-m-d') . '|' . setting('visitor_salt');
// IPv6 addresses rotate freely within a /64, so group them.
if (str_contains($ip, ':')) {
$parts = explode(':', $ip);
$ip = implode(':', array_slice($parts, 0, 4)) . '::/64';
}
return hash('sha256', $salt . '|' . $ip . '|' . $ua, false);
}
// Generate the salt once, on first use, and keep it out of version control.
function ensure_visitor_salt(): void
{
if (trim(setting('visitor_salt')) === '') {
setting_set('visitor_salt', bin2hex(random_bytes(16)), 'security', 'hidden');
}
}
Using it
Store the key, never the inputs. A table of hashes is not a table of addresses, and that difference matters if the database ever leaks.
Truncate to 32 characters if you are indexing it heavily. Collisions at that length are irrelevant for a daily fraud key.
What bites people
Everyone behind one office router or one mobile carrier NAT shares a key. Do not use this to ban, only to limit.
Rotating the salt daily means your limits reset at midnight UTC for everyone at once. If that produces a spike, rotate on a per-visitor offset instead.