All articles

Counting unique visitors without cookies

A daily-rotating salted hash gives you unique-visitor counts with no cookie and no consent banner. How we built it, and the four things it quietly gives up.

10 min readBy gecis.in engineering
  • privacy
  • analytics
  • engineering

Every analytics product has to answer one question before it can answer any other: is this the same person as last time? The industry's default answer for twenty years was a cookie with a random identifier in it. That answer now costs you a consent banner, a legal review, and a meaningful slice of your traffic that clicks "reject".

There is a second answer. It is older, cruder, and — if you are honest about what it cannot do — good enough for the large majority of link analytics. It is a salted hash with a rotating salt, and it is what gecis.in uses.

The mechanism

When a click arrives at a short link, we have no persistent state about the visitor. What we do have is a request. Three fields of it are enough:

  • the client IP address,
  • the User-Agent header,
  • the current date.

We combine them like this:

// lib/ip.ts
export function dailySalt(now: Date = new Date()): string {
  const secret = process.env.IP_HASH_SECRET || DEV_SALT
  const day = now.toISOString().slice(0, 10) // YYYY-MM-DD, UTC
  return `${secret}:${day}`
}
 
export function hashIp(ip: string, salt: string): Buffer {
  return createHash('sha256').update(`${salt}|${ip}`).digest()
}
 
export function hashUa(ua: string): Buffer {
  return createHash('sha256').update(ua).digest()
}
 
/** Same IP + same user agent, same day -> same key. */
export function visitorKey(ipHash: Buffer, uaHash: Buffer): string {
  return createHash('sha256').update(ipHash).update(uaHash).digest('hex').slice(0, 32)
}

The result — a 32-character hex string — goes into the visitor_key column of the click row. Counting people is then a COUNT(DISTINCT …):

SELECT count(*)                     AS clicks,
       count(DISTINCT visitor_key)  AS visitors
FROM click_events
WHERE link_id = $1
  AND clicked_at >= now() - interval '7 days';

That is the whole feature. No cookie is set, no localStorage is written, nothing is read back off the visitor's device, and there is no identifier that survives midnight.

Why the salt rotates

The salt is the interesting part. Hashing an IP address without a secret is close to useless: the IPv4 space is 2^32 addresses, and a laptop can enumerate every SHA-256 of every IPv4 address in minutes. A static secret salt fixes that — but only until the secret leaks. Anyone who obtains it can then re-derive the mapping for every row you have ever stored, going back to the beginning.

Rotating the salt daily bounds that blast radius to a single day, and, more usefully, makes cross-day correlation structurally impossible rather than merely forbidden by policy. Today's hash of 203.0.113.9 and yesterday's hash of 203.0.113.9 are two unrelated 256-bit strings. Nobody — including us, including someone with our database and our current secret — can join them.

That is the property regulators actually care about, and it is worth stating precisely: we are not claiming the data is anonymous. The European Data Protection Board has been consistent that hashing is pseudonymisation, not anonymisation, because the input space is small enough to be re-identified by anyone holding the key.1 Our claim is narrower and defensible: the key is destroyed after 24 hours, so the pseudonym expires.

The cookie banner is not, despite its name, about cookies. It comes from Article 5(3) of the ePrivacy Directive, which requires consent for "the storing of information, or the gaining of access to information already stored, in the terminal equipment of a subscriber or user" — regardless of the technology. That is why localStorage fingerprinting needs consent too, and why the banner text usually says "cookies and similar technologies".

Our scheme stores nothing on the device and reads nothing back off it. The IP address and User-Agent are transmitted by the browser as an unavoidable part of making an HTTP request; we do not access information stored in terminal equipment to obtain them. So Article 5(3) is not engaged.

GDPR still is. An IP address is personal data (Breyer, C‑582/14), and hashing it is processing, so we need a lawful basis — legitimate interest, documented, with the balancing test done — and the click rows are covered by the privacy notice. What we avoid is the consent requirement specifically, which is the expensive one.

Two honest caveats:

  1. This is not legal advice and national regulators differ. France's CNIL publishes an exemption for audience-measurement analytics that meet specific conditions; Germany's TTDSG implementation reads slightly differently. If you are shipping this in a regulated context, have someone look at it against your own jurisdiction.
  2. "No cookie" is not the same as "no privacy question". You still hold a per-day pseudonym linked to an IP address. Design your retention around that, not around the marketing copy.

Our retention is a partition drop, not a promise:

CREATE TABLE click_events (
  ...
  ip      inet,           -- dropped with the partition after 90 days
  ip_hash bytea NOT NULL, -- daily rotating salt
  ...
) PARTITION BY RANGE (clicked_at);

Raw IPs live in monthly partitions. After ninety days the partition is dropped, which is a catalogue operation rather than a DELETE over millions of rows — meaning the deletion actually happens on schedule instead of being a background job somebody disables during an incident.

The four things it gives up

This is the part most write-ups of this technique skip.

1. There is no such thing as a returning visitor

The identifier is scoped to a day. You cannot compute retention, returning-visitor rate, time-to-second-click, or any cohort that spans midnight. If your product roadmap contains "show which visitors came back", this design forecloses it, permanently and by construction. That is a real product decision dressed up as a technical one, and it should be made deliberately.

2. Uniques do not add up

Because the identifier changes at midnight UTC, the sum of seven daily unique counts is not the weekly unique count, and there is no way to compute the weekly one. A person clicking on Monday and Thursday is two visitors in any window that spans both.

This is not a rounding error. For a link that gets steady traffic from a small audience — an internal newsletter, say — "unique visitors this month" computed this way can overstate the real number of humans by a large multiple. We show unique counts per day and over short windows, and we do not offer a "monthly unique visitors" figure, because we would not be able to defend it.

3. NAT collapses people; mobility splits them

The IP is not a person. Two opposite errors follow:

  • Under-counting. A university, an office, or any carrier-grade NAT deployment puts thousands of people behind one address. If they also run a managed fleet with identical browser builds, the User-Agent is identical too, and thousands of humans collapse into one visitor_key. CGNAT (100.64.0.0/10) makes this common on mobile networks.
  • Over-counting. One person who walks out of the office and onto mobile data changes IP mid-session and becomes a second visitor. A browser that auto-updates overnight changes its User-Agent and becomes a third.

Including the User-Agent in the key is a deliberate trade: it reduces NAT collapse, at the price of splitting a single person every time their browser updates. There is no setting that fixes both.

4. It fights with rate limiting

This one bit us, and it is the sort of thing you only find by reading your own code twice.

Link creation is rate limited per IP — ten links an hour, fifty a day — and the limiter reuses the same hash so that no raw IP is stored on the links table either:

SELECT
  count(*) FILTER (WHERE created_at > now() - interval '1 hour') AS hourly,
  count(*) FILTER (WHERE created_at > now() - interval '1 day')  AS daily
FROM links
WHERE creator_ip_hash = $1
  AND created_at > now() - interval '1 day';

Read the WHERE clause carefully. $1 is the creator's IP hashed with today's salt. Rows written before midnight UTC were hashed with yesterday's salt, so they are a different value and the query cannot see them. The "trailing 24 hours" is therefore, in practice, "since midnight UTC" — and at 00:00 UTC every daily counter in the system silently resets.

That is the cost of rotation, and it is a genuine weakening of the limiter: an adversary who understands the scheme gets a fresh fifty-link budget at a predictable moment every day. The available fixes all trade something back:

  • Keep both salts. Hash the creator IP under today's and yesterday's salt and check both. Correct trailing window, two index lookups, and yesterday's salt has to stay in memory for 24 hours.
  • Separate the concerns. Use a rotating salt for analytics and a stable secret salt for the abuse counter. Better limiter, but you have reintroduced the long-lived correlatable identifier — on the table that also stores what people shortened.
  • Move the limiter out of Postgres. A counter keyed by raw IP with a TTL, in memory, never persisted. This is the honest answer for a limiter, and the reason we have not done it yet is that this stack deliberately has no Redis.

We are going with the first. It is the only one that does not reintroduce a durable identifier, and the second index lookup is on a partial index that is already there.

When to use this, and when not to

Use it when you want counts — how many people, roughly, from where, on what. That is what link analytics is for, and for that purpose the technique is honest, cheap, and free of a consent dialogue that would cost you 30% of your data anyway.

Do not use it when you need identity — funnels across days, retention curves, per-user history. Those need consent, and the correct engineering response is to ask for it properly rather than to build a fingerprint that pretends not to be one.

The trap in this space is the middle ground: adding "just one more signal" to the hash — screen size, language, fonts, TLS fingerprint — until it is stable enough to track people across days. At that point you have built a cookieless cookie, which is exactly the thing Article 5(3) was rewritten to cover, and you have done it without the banner that would have made it lawful. The rotating salt is a good design partly because it makes that drift hard: no matter how many signals you add, the identifier still dies at midnight.

Footnotes

  1. Article 29 Working Party, Opinion 05/2014 on Anonymisation Techniques (WP216), which treats keyed-hash pseudonymisation as a technique that reduces but does not eliminate re-identification risk. The EDPB has carried this position forward.

Articles are published in English.

gecis.in Blog · RSS