Skip to content

Count visitors without cookies: the daily salted hash

The daily salted hash step by step, with code you can run, its error in both directions, and a plain table of what it stops and what it doesn’t.

Published on 8 min read

If you don’t store anything in the browser, how do you know that two page views came from one person? You don’t, not for certain. You make a good guess from what each request already carries, and you make sure the guess expires. This post shows exactly how Trailic does it, what it gets wrong, and what it protects against. Most of it applies to any tool built on a salted visitor hash.

The recipe

Every request tells the server two useful things: the address it came from and the browser’s user agent. On their own they’d make a lasting ID, which is what you’re trying to avoid. So:

  1. Take the day, in UTC.
  2. Derive that day’s salt from a secret only the server knows.
  3. Hash the salt, the site, the address and the user agent. In anonymous mode, the default, the address is first shortened: IPv4 to /24, IPv6 to /48.
  4. Keep the result as the day’s visitor, and let the address go.

Here it is, taken from src/infrastructure/crypto/visitor-hash.ts and reduced to something you can run with node visitor.mjs:

import { createHash, createHmac } from "node:crypto";

const dailySalt = (secret, day) =>
  createHmac("sha256", secret).update(`visitor-salt:${day}`).digest("hex");

const visitorOf = ({ secret, siteId, day, address, userAgent }) =>
  createHash("sha256")
    .update(`${dailySalt(secret, day)}\n${siteId}\n${address}\n${userAgent}`)
    .digest("hex")
    .slice(0, 32); // 128 bits, stored as a uuid

console.log(visitorOf({
  secret: "example-secret-do-not-use",
  siteId: "site-a",
  day: "2026-11-06",
  address: "203.0.113.7",
  userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
}));

I ran it, changing one input at a time:

ChangeResult starts with
Noneb82b3467
The next day08ad149c
Another site23b1fdf1
Another address328e90e4
Chrome 142 instead of 141f1904028

Each change gives an unrelated value. That’s the whole design: tomorrow the same person is someone else, and on another site too. After that, the collector keeps a country, a coarse device, browser and system, and the hash. The address and the user agent aren’t stored anywhere, in the buffer or in the database.

Its error goes both ways

It undercounts because everyone with the same address and the same user agent is one visitor. An office of forty people on the same Chrome version, behind one public address, counts as one. Mobile carriers do something similar: carrier-grade NAT, as the EDPB describes it, groups a number of subscribers under the same public address. Browsers are making this more common. Chrome’s User-Agent Reduction freezes the minor version at 0.0.0 and replaces operating system details with fixed values, so strings vary less than they used to.

It overcounts because anything that changes an input makes a new visitor:

  • A phone moving from Wi-Fi to mobile data: two visitors.
  • A browser updating itself during the day: two visitors.
  • A laptop and a phone: two visitors.
  • A session running across midnight UTC: two visitors. That’s 7pm in New York in winter, so it’s not a quiet hour for everyone.

Here’s what that does to one day, with invented numbers to show the arithmetic:

Effect
Real people that day1,000
120 of them share an address and browser with someone, and collapse into 50−70
100 of them change network during the day+100
40 of them use two devices+40
Counted1,070

The total is only 7% off, yet 210 counts went wrong underneath, in opposite directions, and you can’t see any of it from the number. That’s why I treat this as a good trend line. Today against last Tuesday, with the same method on the same site, says something. The absolute number deserves less trust.

Periods add up. Since a visitor is one per site per day, a week’s visitors are the sum of its days. Someone who comes on three days counts three times, and there’s no way to de-duplicate them, by design. So “visitors this month” in Trailic means person-days. Across sites, a workspace’s visitors add up its sites the same way.

Accidental collisions aren’t the problem. With 128 bits and a billion visitors in a day, the chance of two different inputs colliding is about 1.5 × 10⁻²¹ (n² ÷ 2¹²⁹). Wrong counts come from shared inputs.

What it stops, and what it doesn’t

WhoWhat they can do
Someone reading the databaseSee hashes, paths, countries. For anonymous visitors, they can’t tell who visited, or link one day to the next, or one site to another. Identified people are there under the ID you sent
A visitor’s browserNothing to read: no cookie, and nothing in storage before identify
Someone with TRAILIC_SECRET who knows a person’s address and browserCompute that person’s hash for any day, and look them up
Someone with the secret and no suspectTry addresses. There are 2³² IPv4 addresses, so at a million hashes a second one known browser string takes about 72 minutes. IPv6 is far too large for this
Your hostSee addresses in its own logs, as with any web request. The hash changes nothing there
Anyone on the internetSend fake events, since site keys are public. Domain checks and limits make it harder, not impossible
Two people behind one address with one browserGet merged, and in the half hour before identify, get mixed up

The third and fourth rows come from a choice worth arguing about. In their docs, checked November 2026, Plausible says its salt is rotated and deleted every 24 hours. PostHog says it deletes each salt once that day’s events are processed. Matomo says its random seed is discarded each day and can’t be recovered. Trailic doesn’t store a salt at all: it derives it from the secret, so that collecting an event never has to read a database, which lets the database sleep on a serverless plan. The price is that old days stay computable by whoever holds the secret. If you run an instance, protect that secret as you would your database password. Changing it ends every site key in every project, and makes every visitor new for the rest of that day.

If you think deleting salts is worth a database read, or a different design, open an issue. I’d like to hear it.

A hash isn’t anonymisation

Don’t let “hashed” carry more weight than it can. The GDPR’s recital 26 treats pseudonymised data that can be attributed with additional information as concerning an identifiable person. The EDPB’s draft guidelines on pseudonymisation add that preventing attribution doesn’t imply anonymity. The Article 29 Working Party’s opinion on anonymisation techniques calls pseudonymisation a useful security measure and not a method of anonymisation. It lists the keyed hash with a stored key, which is this design: the controller can re-run the function, and an attacker without the key can’t, in practice. Deleting the key, as the three tools above describe, lowers the risk of linking further. Vendors sometimes call their hash anonymous. Since an operator of this one can recompute it, I wouldn’t, and I’d treat it as pseudonymous personal data until a lawyer tells you otherwise.

On the regulator side, the CNIL’s self-assessment grid accepts a hash built on device characteristics if it includes a site-specific part and a time-based part, and this one has both. Its page on proxying analytics data, which concerns transfers outside the EU, also asks for a sufficient level of collision, and 128 bits has none to speak of. And none of that makes anything exempt: see the cookie banner question.

If you build your own

  • Put the site in the hash, or two of your sites can compare visitors.
  • Put the day in the hash, or you’ve built a lasting ID.
  • Never log the address next to the result. A request log kept for a month undoes the rotation.
  • Check what your proxy does to addresses. Vercel overwrites X-Forwarded-For on what it receives, so a plain rewrite between two Vercel projects makes the collector see the proxy’s address, and every visitor merges into one. Trailic has a relay for that, described in how to run it.

The privacy approach page lists everything else that’s collected.

Where Trailic stands

Trailic is a first version. It works end to end on a test instance with real browsers, a real assistant and five million synthetic events, but it hasn’t collected real traffic yet, so the error above is reasoned rather than measured. Its Vercel Queues buffer has only run against a stand-in for that service, its data model may change, and there’s no upgrade path between versions. There’s no hosted version, and the library isn’t on npm yet: it lives in the repo at packages/trailic. Don’t make it your only analytics yet. Read the code before you trust this description of it.

Sources

Related articles

What cookieless analytics means, how six tools count people without cookies, what each loses, and why no cookies doesn’t settle the consent question.
When analytics need consent under ePrivacy and GDPR, why no cookies isn’t the test, and each condition of the CNIL exemption. General information.